@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.
- package/dist/components/control_area/index.d.ts +0 -0
- package/dist/components/cursor/cursor.d.ts +20 -0
- package/dist/components/cut-overlay/CutOverlay.d.ts +202 -0
- package/dist/components/edit_area/cross_row_drag.d.ts +50 -0
- package/dist/components/edit_area/drag_lines.d.ts +11 -0
- package/dist/components/edit_area/drag_preview.d.ts +14 -0
- package/dist/components/edit_area/drag_utils.d.ts +39 -0
- package/dist/components/edit_area/edit_action.d.ts +19 -0
- package/dist/components/edit_area/edit_area.d.ts +56 -0
- package/dist/components/edit_area/edit_row.d.ts +27 -0
- package/dist/components/edit_area/hooks/use_drag_line.d.ts +33 -0
- package/dist/components/edit_area/insertion_line.d.ts +12 -0
- package/dist/components/loop-zone/LoopZoneOverlay.d.ts +243 -0
- package/dist/components/row_rnd/hooks/useAutoScroll.d.ts +7 -0
- package/dist/components/row_rnd/interactable.d.ts +14 -0
- package/dist/components/row_rnd/row_rnd.d.ts +3 -0
- package/dist/components/row_rnd/row_rnd_interface.d.ts +47 -0
- package/dist/components/time_area/time_area.d.ts +17 -0
- package/dist/components/timeline.d.ts +3 -0
- package/dist/components/transport/TransportBar.d.ts +132 -0
- package/dist/components/transport/useTimelinePlayer.d.ts +164 -0
- package/dist/index.cjs.js +13 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.es.js +10102 -0
- package/dist/index.umd.js +13 -0
- package/dist/interface/common_prop.d.ts +12 -0
- package/dist/interface/const.d.ts +28 -0
- package/dist/interface/timeline.d.ts +342 -0
- package/dist/react-timeline-editor.css +1 -0
- package/dist/utils/check_props.d.ts +2 -0
- package/dist/utils/deal_class_prefix.d.ts +1 -0
- package/dist/utils/deal_data.d.ts +58 -0
- package/dist/utils/logger.d.ts +132 -0
- package/package.json +70 -0
- package/src/components/control_area/index.tsx +1 -0
- package/src/components/cursor/cursor.css +26 -0
- package/src/components/cursor/cursor.tsx +105 -0
- package/src/components/cut-overlay/CutOverlay.css +68 -0
- package/src/components/cut-overlay/CutOverlay.tsx +491 -0
- package/src/components/edit_area/cross_row_drag.tsx +174 -0
- package/src/components/edit_area/drag_lines.css +13 -0
- package/src/components/edit_area/drag_lines.tsx +31 -0
- package/src/components/edit_area/drag_preview.tsx +50 -0
- package/src/components/edit_area/drag_utils.ts +77 -0
- package/src/components/edit_area/edit_action.css +56 -0
- package/src/components/edit_area/edit_action.tsx +362 -0
- package/src/components/edit_area/edit_area.css +24 -0
- package/src/components/edit_area/edit_area.tsx +606 -0
- package/src/components/edit_area/edit_row.css +78 -0
- package/src/components/edit_area/edit_row.tsx +128 -0
- package/src/components/edit_area/hooks/use_drag_line.ts +93 -0
- package/src/components/edit_area/insertion_line.tsx +39 -0
- package/src/components/loop-zone/LoopZoneOverlay.css +65 -0
- package/src/components/loop-zone/LoopZoneOverlay.tsx +461 -0
- package/src/components/row_rnd/hooks/useAutoScroll.ts +81 -0
- package/src/components/row_rnd/interactable.tsx +55 -0
- package/src/components/row_rnd/row_rnd.tsx +365 -0
- package/src/components/row_rnd/row_rnd_interface.ts +59 -0
- package/src/components/time_area/time_area.css +35 -0
- package/src/components/time_area/time_area.tsx +93 -0
- package/src/components/timeline.css +12 -0
- package/src/components/timeline.tsx +227 -0
- package/src/components/transport/TransportBar.css +171 -0
- package/src/components/transport/TransportBar.tsx +322 -0
- package/src/components/transport/useTimelinePlayer.ts +319 -0
- package/src/index.tsx +17 -0
- package/src/interface/common_prop.ts +13 -0
- package/src/interface/const.ts +32 -0
- package/src/interface/timeline.ts +329 -0
- package/src/utils/check_props.ts +77 -0
- package/src/utils/deal_class_prefix.ts +6 -0
- package/src/utils/deal_data.ts +159 -0
- 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;
|