@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,606 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useEffect,
|
|
3
|
+
useImperativeHandle,
|
|
4
|
+
useLayoutEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
useCallback,
|
|
8
|
+
} from 'react';
|
|
9
|
+
import { AutoSizer, Grid, GridCellRenderer, OnScrollParams, ScrollParams } from 'react-virtualized';
|
|
10
|
+
import { TimelineAction, TimelineRow } from '@keplar-404/timeline-engine';
|
|
11
|
+
import { CommonProp } from '../../interface/common_prop';
|
|
12
|
+
import { EditData } from '../../interface/timeline';
|
|
13
|
+
import { prefix } from '../../utils/deal_class_prefix';
|
|
14
|
+
import { parserPixelToTime, parserTimeToPixel } from '../../utils/deal_data';
|
|
15
|
+
import { DragLines } from './drag_lines';
|
|
16
|
+
import './edit_area.css';
|
|
17
|
+
import { EditRow } from './edit_row';
|
|
18
|
+
import { useDragLine } from './hooks/use_drag_line';
|
|
19
|
+
import { calculateTotalHeight, getRowHeights, isValidDragTarget } from './drag_utils';
|
|
20
|
+
import { InsertionLine } from './insertion_line';
|
|
21
|
+
import { DragPreview } from './drag_preview';
|
|
22
|
+
import {
|
|
23
|
+
CrossRowDragProvider,
|
|
24
|
+
CrossRowGhost,
|
|
25
|
+
CrossRowDragState,
|
|
26
|
+
useCrossRowDrag,
|
|
27
|
+
} from './cross_row_drag';
|
|
28
|
+
|
|
29
|
+
export type EditAreaProps = CommonProp & {
|
|
30
|
+
/** Horizontal scroll offset */
|
|
31
|
+
scrollLeft: number;
|
|
32
|
+
/** Vertical scroll offset */
|
|
33
|
+
scrollTop: number;
|
|
34
|
+
/** Scroll callback for sync */
|
|
35
|
+
onScroll: (params: OnScrollParams) => void;
|
|
36
|
+
/** Update the editor data */
|
|
37
|
+
setEditorData: (params: TimelineRow[]) => void;
|
|
38
|
+
/** Horizontal auto-scroll delta */
|
|
39
|
+
deltaScrollLeft: (scrollLeft: number) => void;
|
|
40
|
+
/** Enable cross-row block drag */
|
|
41
|
+
enableCrossRowDrag?: boolean;
|
|
42
|
+
/** Show ghost preview while block dragging across rows */
|
|
43
|
+
enableGhostPreview?: boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Custom render function for the drag ghost element.
|
|
46
|
+
* Replaces the default blue glowing box with your own component.
|
|
47
|
+
*/
|
|
48
|
+
getGhostPreview?: (params: { action: TimelineAction; row: TimelineRow }) => React.ReactNode;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Edit area ref data */
|
|
52
|
+
export interface EditAreaState {
|
|
53
|
+
domRef: React.MutableRefObject<HTMLDivElement | null>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ─────────────────────────────────────────────
|
|
57
|
+
// Row-reorder drag state (existing feature)
|
|
58
|
+
// ─────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
interface RowDragState {
|
|
61
|
+
isDragging: boolean;
|
|
62
|
+
draggedRow: TimelineRow | null;
|
|
63
|
+
draggedIndex: number;
|
|
64
|
+
targetIndex: number;
|
|
65
|
+
placeholderIndex: number;
|
|
66
|
+
originalData: TimelineRow[];
|
|
67
|
+
insertionLine: { visible: boolean; position: 'top' | 'bottom'; index: number };
|
|
68
|
+
dragPreview: { visible: boolean; top: number; left: number; width: number; height: number };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const initialRowDragState: RowDragState = {
|
|
72
|
+
isDragging: false,
|
|
73
|
+
draggedRow: null,
|
|
74
|
+
draggedIndex: -1,
|
|
75
|
+
targetIndex: -1,
|
|
76
|
+
placeholderIndex: -1,
|
|
77
|
+
originalData: [],
|
|
78
|
+
insertionLine: { visible: false, position: 'top', index: -1 },
|
|
79
|
+
dragPreview: { visible: false, top: 0, left: 0, width: 0, height: 0 },
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// ─────────────────────────────────────────────
|
|
83
|
+
// Inner component (has access to CrossRowDragProvider context)
|
|
84
|
+
// ─────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
const EditAreaInner = React.forwardRef<EditAreaState, EditAreaProps>((props, ref) => {
|
|
87
|
+
const {
|
|
88
|
+
editorData,
|
|
89
|
+
rowHeight,
|
|
90
|
+
scaleWidth,
|
|
91
|
+
scaleCount,
|
|
92
|
+
scaleSplitCount,
|
|
93
|
+
startLeft,
|
|
94
|
+
scrollLeft,
|
|
95
|
+
scrollTop,
|
|
96
|
+
scale,
|
|
97
|
+
gridSnap,
|
|
98
|
+
hideCursor = false,
|
|
99
|
+
cursorTime,
|
|
100
|
+
onScroll,
|
|
101
|
+
dragLine,
|
|
102
|
+
getAssistDragLineActionIds,
|
|
103
|
+
onActionMoveEnd,
|
|
104
|
+
onActionMoveStart,
|
|
105
|
+
onActionMoving,
|
|
106
|
+
onActionResizeEnd,
|
|
107
|
+
onActionResizeStart,
|
|
108
|
+
onActionResizing,
|
|
109
|
+
enableRowDrag = false,
|
|
110
|
+
onRowDragStart,
|
|
111
|
+
onRowDragEnd,
|
|
112
|
+
setEditorData,
|
|
113
|
+
enableCrossRowDrag = false,
|
|
114
|
+
enableGhostPreview = true,
|
|
115
|
+
getGhostPreview,
|
|
116
|
+
} = props;
|
|
117
|
+
|
|
118
|
+
const { dragLineData, initDragLine, updateDragLine, disposeDragLine, defaultGetAssistPosition, defaultGetMovePosition } =
|
|
119
|
+
useDragLine();
|
|
120
|
+
|
|
121
|
+
const editAreaRef = useRef<HTMLDivElement>(null);
|
|
122
|
+
const gridRef = useRef<Grid>(null);
|
|
123
|
+
const heightRef = useRef(-1);
|
|
124
|
+
const scrollToPositionRef = useRef({ scrollLeft, scrollTop });
|
|
125
|
+
const onScrollParamsRef = useRef<ScrollParams>({
|
|
126
|
+
clientHeight: 0,
|
|
127
|
+
clientWidth: 0,
|
|
128
|
+
scrollHeight: 0,
|
|
129
|
+
scrollLeft: 0,
|
|
130
|
+
scrollTop: 0,
|
|
131
|
+
scrollWidth: 0,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Row reorder drag state
|
|
135
|
+
const [dragState, setDragState] = useState<RowDragState>(initialRowDragState);
|
|
136
|
+
|
|
137
|
+
// Cross-row drag context
|
|
138
|
+
const crossRowDrag = useCrossRowDrag();
|
|
139
|
+
|
|
140
|
+
// Ghost state (mirrors context state so we can render without extra subscription)
|
|
141
|
+
const [ghostState, setGhostState] = useState<CrossRowDragState>(crossRowDrag.state);
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
setGhostState(crossRowDrag.state);
|
|
145
|
+
}, [crossRowDrag.state]);
|
|
146
|
+
|
|
147
|
+
// ─── ref ───
|
|
148
|
+
useImperativeHandle(ref, () => ({ get domRef() { return editAreaRef; } }));
|
|
149
|
+
|
|
150
|
+
// ─────────────────────────────────────────────
|
|
151
|
+
// Cross-row drag: resolve commit
|
|
152
|
+
// ─────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Given a clientX position, compute new start/end times for the action placed
|
|
156
|
+
* into the target row.
|
|
157
|
+
*
|
|
158
|
+
* When `gridSnap` is enabled the start time is rounded to the nearest grid
|
|
159
|
+
* unit (`scale / scaleSplitCount`) — the same snapping applied by `RowDnd`
|
|
160
|
+
* during same-row drags. The end time is always derived as `start + duration`
|
|
161
|
+
* so the block length is preserved exactly.
|
|
162
|
+
*/
|
|
163
|
+
const resolveNewTimes = useCallback(
|
|
164
|
+
(clientX: number, action: TimelineAction, grabOffsetX: number): { newStart: number; newEnd: number } => {
|
|
165
|
+
if (!editAreaRef.current) return { newStart: action.start, newEnd: action.end };
|
|
166
|
+
|
|
167
|
+
const rect = editAreaRef.current.getBoundingClientRect();
|
|
168
|
+
// x relative to the edit area (accounting for current horizontal scroll)
|
|
169
|
+
const x = clientX - rect.left + scrollToPositionRef.current.scrollLeft - grabOffsetX;
|
|
170
|
+
const rawStart = parserPixelToTime(x, { startLeft, scale, scaleWidth });
|
|
171
|
+
|
|
172
|
+
// Apply the same grid-snap rounding that RowDnd uses during same-row drags
|
|
173
|
+
const gridUnit: number = scale / scaleSplitCount;
|
|
174
|
+
const snappedStart: number = gridSnap
|
|
175
|
+
? Math.round(rawStart / gridUnit) * gridUnit
|
|
176
|
+
: rawStart;
|
|
177
|
+
|
|
178
|
+
const duration: number = action.end - action.start;
|
|
179
|
+
const newStart = Math.max(0, snappedStart);
|
|
180
|
+
const newEnd = Math.max(duration, newStart + duration);
|
|
181
|
+
return { newStart, newEnd };
|
|
182
|
+
},
|
|
183
|
+
[startLeft, scale, scaleWidth, gridSnap, scaleSplitCount],
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Given a clientY, determine which row index the cursor is over.
|
|
188
|
+
*/
|
|
189
|
+
const resolveTargetRow = useCallback(
|
|
190
|
+
(clientY: number): number => {
|
|
191
|
+
if (!editAreaRef.current) return -1;
|
|
192
|
+
|
|
193
|
+
const rect = editAreaRef.current.getBoundingClientRect();
|
|
194
|
+
const viewportY = clientY - rect.top + scrollToPositionRef.current.scrollTop;
|
|
195
|
+
|
|
196
|
+
let currentTop = 0;
|
|
197
|
+
for (let i = 0; i < editorData.length; i++) {
|
|
198
|
+
const rh = editorData[i]?.rowHeight || rowHeight;
|
|
199
|
+
if (viewportY >= currentTop && viewportY < currentTop + rh) return i;
|
|
200
|
+
currentTop += rh;
|
|
201
|
+
}
|
|
202
|
+
return editorData.length - 1;
|
|
203
|
+
},
|
|
204
|
+
[editorData, rowHeight],
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
// Register the commit handler into the cross-row context
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
crossRowDrag.setOnCommit((action, sourceRow, _placeholder, clientX, clientY) => {
|
|
210
|
+
const targetRowIndex = resolveTargetRow(clientY as unknown as number);
|
|
211
|
+
if (targetRowIndex < 0) return;
|
|
212
|
+
|
|
213
|
+
const targetRow = editorData[targetRowIndex];
|
|
214
|
+
if (!targetRow) return;
|
|
215
|
+
|
|
216
|
+
// Don't move if dropped on the same row
|
|
217
|
+
if (targetRow.id === sourceRow.id) {
|
|
218
|
+
// Still update position within the same row from cursor X
|
|
219
|
+
const { newStart, newEnd } = resolveNewTimes(clientX as unknown as number, action, crossRowDrag.state.grabOffsetX);
|
|
220
|
+
const newData = editorData.map((r) => {
|
|
221
|
+
if (r.id !== sourceRow.id) return r;
|
|
222
|
+
return {
|
|
223
|
+
...r,
|
|
224
|
+
actions: r.actions.map((a) => (a.id !== action.id ? a : { ...a, start: newStart, end: newEnd })),
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
setEditorData(newData);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Cross-row: remove from source, add to target
|
|
232
|
+
const { newStart, newEnd } = resolveNewTimes(clientX as unknown as number, action, crossRowDrag.state.grabOffsetX);
|
|
233
|
+
const updatedAction: TimelineAction = { ...action, start: newStart, end: newEnd };
|
|
234
|
+
|
|
235
|
+
const newData = editorData.map((r) => {
|
|
236
|
+
if (r.id === sourceRow.id) {
|
|
237
|
+
return { ...r, actions: r.actions.filter((a) => a.id !== action.id) };
|
|
238
|
+
}
|
|
239
|
+
if (r.id === targetRow.id) {
|
|
240
|
+
return { ...r, actions: [...r.actions, updatedAction] };
|
|
241
|
+
}
|
|
242
|
+
return r;
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
setEditorData(newData);
|
|
246
|
+
});
|
|
247
|
+
}, [crossRowDrag, editorData, resolveTargetRow, resolveNewTimes, setEditorData]);
|
|
248
|
+
|
|
249
|
+
// ─────────────────────────────────────────────
|
|
250
|
+
// Row reorder drag (existing feature)
|
|
251
|
+
// ─────────────────────────────────────────────
|
|
252
|
+
|
|
253
|
+
const reorderRows = useCallback((data: TimelineRow[], fromIndex: number, toIndex: number): TimelineRow[] => {
|
|
254
|
+
const result = [...data];
|
|
255
|
+
if (toIndex > fromIndex && toIndex < data.length) toIndex = toIndex - 1;
|
|
256
|
+
const [removed] = result.splice(fromIndex, 1);
|
|
257
|
+
result.splice(toIndex, 0, removed);
|
|
258
|
+
return result;
|
|
259
|
+
}, []);
|
|
260
|
+
|
|
261
|
+
const calculateTargetIndex = useCallback(
|
|
262
|
+
(clientY: number): { index: number; position: 'top' | 'bottom' } => {
|
|
263
|
+
if (!editAreaRef.current) return { index: -1, position: 'top' };
|
|
264
|
+
const rect = editAreaRef.current.getBoundingClientRect();
|
|
265
|
+
const viewportTop = clientY - rect.top + scrollToPositionRef.current.scrollTop;
|
|
266
|
+
const rowCount = editorData.length;
|
|
267
|
+
let currentTop = 0;
|
|
268
|
+
if (rowCount > 0 && viewportTop < 0) return { index: 0, position: 'top' };
|
|
269
|
+
for (let i = 0; i < rowCount; i++) {
|
|
270
|
+
const rh = editorData[i]?.rowHeight || rowHeight;
|
|
271
|
+
const bottom = currentTop + rh;
|
|
272
|
+
if (viewportTop >= currentTop && viewportTop <= bottom) {
|
|
273
|
+
return Math.abs(viewportTop - currentTop) < Math.abs(viewportTop - bottom)
|
|
274
|
+
? { index: i, position: 'top' }
|
|
275
|
+
: { index: i + 1, position: 'top' };
|
|
276
|
+
}
|
|
277
|
+
currentTop = bottom;
|
|
278
|
+
}
|
|
279
|
+
return { index: rowCount, position: 'top' };
|
|
280
|
+
},
|
|
281
|
+
[editorData, rowHeight],
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
const calculateDragPreviewPosition = useCallback(
|
|
285
|
+
(clientY: number, previewHeight: number) => {
|
|
286
|
+
if (!editAreaRef.current) return 0;
|
|
287
|
+
const rect = editAreaRef.current.getBoundingClientRect();
|
|
288
|
+
let top = clientY - rect.top - previewHeight / 2;
|
|
289
|
+
top = Math.max(0, Math.min(top, rect.height - previewHeight));
|
|
290
|
+
return top;
|
|
291
|
+
},
|
|
292
|
+
[scrollTop],
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const calculateRowAccumulatedHeight = useCallback(
|
|
296
|
+
(rowIndex: number): number => {
|
|
297
|
+
let h = 0;
|
|
298
|
+
for (let i = 0; i < rowIndex; i++) h += editorData[i]?.rowHeight || rowHeight;
|
|
299
|
+
return Math.max(0, h - scrollTop);
|
|
300
|
+
},
|
|
301
|
+
[editorData, scrollTop, rowHeight],
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
const initializeDragState = useCallback(
|
|
305
|
+
(row: TimelineRow, rowIndex: number) => {
|
|
306
|
+
const originalData = [...editorData];
|
|
307
|
+
const ph = row.rowHeight || rowHeight;
|
|
308
|
+
return {
|
|
309
|
+
isDragging: true,
|
|
310
|
+
draggedRow: row,
|
|
311
|
+
draggedIndex: rowIndex,
|
|
312
|
+
targetIndex: -1,
|
|
313
|
+
placeholderIndex: rowIndex,
|
|
314
|
+
originalData,
|
|
315
|
+
insertionLine: { visible: false, position: 'top' as const, index: -1 },
|
|
316
|
+
dragPreview: { visible: true, top: calculateRowAccumulatedHeight(rowIndex), left: 0, width: 0, height: ph },
|
|
317
|
+
};
|
|
318
|
+
},
|
|
319
|
+
[editorData, rowHeight, calculateRowAccumulatedHeight],
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
const updateRowDragState = useCallback(
|
|
323
|
+
(currentState: RowDragState, targetIndex: number, previewTop: number, rowIndex: number) => {
|
|
324
|
+
if (currentState.targetIndex === targetIndex && currentState.dragPreview.top === previewTop)
|
|
325
|
+
return currentState;
|
|
326
|
+
return {
|
|
327
|
+
...currentState,
|
|
328
|
+
targetIndex,
|
|
329
|
+
placeholderIndex: targetIndex > rowIndex ? targetIndex - 1 : targetIndex,
|
|
330
|
+
insertionLine: {
|
|
331
|
+
visible: targetIndex !== -1 && targetIndex !== rowIndex,
|
|
332
|
+
position: 'top' as const,
|
|
333
|
+
index: targetIndex,
|
|
334
|
+
},
|
|
335
|
+
dragPreview: { ...currentState.dragPreview, top: previewTop },
|
|
336
|
+
};
|
|
337
|
+
},
|
|
338
|
+
[],
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
const handleRowDragEnd = useCallback(
|
|
342
|
+
(draggedIndex: number, targetIndex: number, draggedRow: TimelineRow | null, originalData: TimelineRow[]) => {
|
|
343
|
+
if (isValidDragTarget(targetIndex, draggedIndex, editorData.length)) {
|
|
344
|
+
const newData = reorderRows(editorData, draggedIndex, targetIndex);
|
|
345
|
+
setEditorData(newData);
|
|
346
|
+
onRowDragEnd?.({ row: draggedRow!, editorData: newData });
|
|
347
|
+
} else {
|
|
348
|
+
setEditorData(originalData);
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
[editorData, reorderRows, setEditorData, onRowDragEnd],
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
const handleRowDragStart = useCallback(
|
|
355
|
+
(row: TimelineRow, rowIndex: number) => {
|
|
356
|
+
if (!enableRowDrag) return;
|
|
357
|
+
const initState = initializeDragState(row, rowIndex);
|
|
358
|
+
setDragState(initState);
|
|
359
|
+
onRowDragStart?.({ row });
|
|
360
|
+
|
|
361
|
+
let animationFrameId: number | null = null;
|
|
362
|
+
let autoScrollAnimationFrameId: number | null = null;
|
|
363
|
+
let lastUpdateTime = 0;
|
|
364
|
+
const UPDATE_INTERVAL = 16;
|
|
365
|
+
let currentMouseY = 0;
|
|
366
|
+
let isDraggingLocal = false;
|
|
367
|
+
|
|
368
|
+
const updateDragPosition = (mouseY: number) => {
|
|
369
|
+
const now = Date.now();
|
|
370
|
+
if (now - lastUpdateTime >= UPDATE_INTERVAL) {
|
|
371
|
+
const targetInfo = calculateTargetIndex(mouseY);
|
|
372
|
+
const previewTop = calculateDragPreviewPosition(mouseY, initState.dragPreview.height);
|
|
373
|
+
setDragState((prev) => updateRowDragState(prev, targetInfo.index, previewTop, rowIndex));
|
|
374
|
+
lastUpdateTime = now;
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const checkAndScroll = () => {
|
|
379
|
+
if (!isDraggingLocal) return (autoScrollAnimationFrameId = requestAnimationFrame(checkAndScroll)) as unknown as void;
|
|
380
|
+
|
|
381
|
+
if (editAreaRef.current && gridRef.current) {
|
|
382
|
+
const gridEl = editAreaRef.current.querySelector('.ReactVirtualized__Grid') as HTMLElement | null;
|
|
383
|
+
if (gridEl) {
|
|
384
|
+
const gr = gridEl.getBoundingClientRect();
|
|
385
|
+
const mouseY = currentMouseY - gr.top;
|
|
386
|
+
const threshold = 50;
|
|
387
|
+
const speed = 10;
|
|
388
|
+
if (mouseY < threshold) {
|
|
389
|
+
onScroll({ ...onScrollParamsRef.current, scrollTop: Math.max(0, gridEl.scrollTop - speed) });
|
|
390
|
+
updateDragPosition(currentMouseY);
|
|
391
|
+
} else if (mouseY > gr.height - threshold) {
|
|
392
|
+
onScroll({
|
|
393
|
+
...onScrollParamsRef.current,
|
|
394
|
+
scrollTop: Math.min(gridEl.scrollTop + speed, gridEl.scrollHeight - gr.height),
|
|
395
|
+
});
|
|
396
|
+
updateDragPosition(currentMouseY);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
autoScrollAnimationFrameId = requestAnimationFrame(checkAndScroll);
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
autoScrollAnimationFrameId = requestAnimationFrame(checkAndScroll);
|
|
404
|
+
|
|
405
|
+
const handleMouseMove = (me: MouseEvent) => {
|
|
406
|
+
isDraggingLocal = true;
|
|
407
|
+
currentMouseY = me.clientY;
|
|
408
|
+
if (animationFrameId) cancelAnimationFrame(animationFrameId);
|
|
409
|
+
animationFrameId = requestAnimationFrame(() => {
|
|
410
|
+
updateDragPosition(currentMouseY);
|
|
411
|
+
animationFrameId = null;
|
|
412
|
+
});
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const handleMouseUp = () => {
|
|
416
|
+
isDraggingLocal = false;
|
|
417
|
+
if (animationFrameId) { cancelAnimationFrame(animationFrameId); animationFrameId = null; }
|
|
418
|
+
if (autoScrollAnimationFrameId) { cancelAnimationFrame(autoScrollAnimationFrameId); autoScrollAnimationFrameId = null; }
|
|
419
|
+
|
|
420
|
+
setDragState((prevState) => {
|
|
421
|
+
const { draggedIndex, targetIndex, originalData, draggedRow } = prevState;
|
|
422
|
+
setTimeout(() => handleRowDragEnd(draggedIndex, targetIndex, draggedRow, originalData), 0);
|
|
423
|
+
return {
|
|
424
|
+
...initialRowDragState,
|
|
425
|
+
dragPreview: { ...initialRowDragState.dragPreview, visible: false },
|
|
426
|
+
insertionLine: { ...initialRowDragState.insertionLine, visible: false },
|
|
427
|
+
};
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
document.removeEventListener('mousemove', handleMouseMove);
|
|
431
|
+
document.removeEventListener('mouseup', handleMouseUp);
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
document.addEventListener('mousemove', handleMouseMove);
|
|
435
|
+
document.addEventListener('mouseup', handleMouseUp);
|
|
436
|
+
},
|
|
437
|
+
[enableRowDrag, onRowDragStart, initializeDragState, calculateTargetIndex, calculateDragPreviewPosition, updateRowDragState, handleRowDragEnd, scrollTop],
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
// ─────────────────────────────────────────────
|
|
441
|
+
// Drag line (same-row assist lines)
|
|
442
|
+
// ─────────────────────────────────────────────
|
|
443
|
+
|
|
444
|
+
const handleInitDragLine: EditData['onActionMoveStart'] = (data) => {
|
|
445
|
+
if (dragLine) {
|
|
446
|
+
const assistActionIds =
|
|
447
|
+
getAssistDragLineActionIds &&
|
|
448
|
+
getAssistDragLineActionIds({ action: data.action, row: data.row, editorData });
|
|
449
|
+
const cursorLeft = parserTimeToPixel(cursorTime, { scaleWidth, scale, startLeft });
|
|
450
|
+
const assistPositions = defaultGetAssistPosition({
|
|
451
|
+
editorData,
|
|
452
|
+
assistActionIds,
|
|
453
|
+
action: data.action,
|
|
454
|
+
row: data.row,
|
|
455
|
+
scale,
|
|
456
|
+
scaleWidth,
|
|
457
|
+
startLeft,
|
|
458
|
+
hideCursor,
|
|
459
|
+
cursorLeft,
|
|
460
|
+
});
|
|
461
|
+
initDragLine({ assistPositions });
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
const handleUpdateDragLine: EditData['onActionMoving'] = (data) => {
|
|
466
|
+
if (dragLine) {
|
|
467
|
+
const movePositions = defaultGetMovePosition({ ...data, startLeft, scaleWidth, scale });
|
|
468
|
+
updateDragLine({ movePositions });
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
// ─────────────────────────────────────────────
|
|
473
|
+
// Cell renderer
|
|
474
|
+
// ─────────────────────────────────────────────
|
|
475
|
+
|
|
476
|
+
const cellRenderer: GridCellRenderer = ({ rowIndex, key, style }) => {
|
|
477
|
+
const row = editorData[rowIndex];
|
|
478
|
+
return (
|
|
479
|
+
<EditRow
|
|
480
|
+
{...props}
|
|
481
|
+
style={{
|
|
482
|
+
...style,
|
|
483
|
+
backgroundPositionX: `0, ${startLeft}px`,
|
|
484
|
+
backgroundSize: `${startLeft}px, ${scaleWidth}px`,
|
|
485
|
+
}}
|
|
486
|
+
areaRef={editAreaRef}
|
|
487
|
+
key={key}
|
|
488
|
+
rowHeight={row?.rowHeight || rowHeight}
|
|
489
|
+
rowData={row}
|
|
490
|
+
dragLineData={dragLineData}
|
|
491
|
+
rowIndex={rowIndex}
|
|
492
|
+
dragState={{ isDragging: dragState.draggedIndex !== -1, draggedIndex: dragState.draggedIndex }}
|
|
493
|
+
enableCrossRowDrag={enableCrossRowDrag}
|
|
494
|
+
enableGhostPreview={enableGhostPreview}
|
|
495
|
+
onRowDragStart={(params) => handleRowDragStart(params.row, rowIndex)}
|
|
496
|
+
onActionMoveStart={(data) => {
|
|
497
|
+
handleInitDragLine(data);
|
|
498
|
+
return onActionMoveStart && onActionMoveStart(data);
|
|
499
|
+
}}
|
|
500
|
+
onActionResizeStart={(data) => {
|
|
501
|
+
handleInitDragLine(data);
|
|
502
|
+
return onActionResizeStart && onActionResizeStart(data);
|
|
503
|
+
}}
|
|
504
|
+
onActionMoving={(data) => {
|
|
505
|
+
handleUpdateDragLine(data);
|
|
506
|
+
return onActionMoving && onActionMoving(data);
|
|
507
|
+
}}
|
|
508
|
+
onActionResizing={(data) => {
|
|
509
|
+
handleUpdateDragLine(data);
|
|
510
|
+
return onActionResizing && onActionResizing(data);
|
|
511
|
+
}}
|
|
512
|
+
onActionResizeEnd={(data) => {
|
|
513
|
+
disposeDragLine();
|
|
514
|
+
return onActionResizeEnd && onActionResizeEnd(data);
|
|
515
|
+
}}
|
|
516
|
+
onActionMoveEnd={(data) => {
|
|
517
|
+
disposeDragLine();
|
|
518
|
+
return onActionMoveEnd && onActionMoveEnd(data);
|
|
519
|
+
}}
|
|
520
|
+
/>
|
|
521
|
+
);
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
// ─────────────────────────────────────────────
|
|
525
|
+
// Scroll sync
|
|
526
|
+
// ─────────────────────────────────────────────
|
|
527
|
+
|
|
528
|
+
useLayoutEffect(() => {
|
|
529
|
+
gridRef.current?.scrollToPosition({ scrollTop, scrollLeft });
|
|
530
|
+
scrollToPositionRef.current = { scrollTop, scrollLeft };
|
|
531
|
+
}, [scrollTop, scrollLeft]);
|
|
532
|
+
|
|
533
|
+
useEffect(() => {
|
|
534
|
+
gridRef.current?.recomputeGridSize();
|
|
535
|
+
}, [editorData]);
|
|
536
|
+
|
|
537
|
+
// ─────────────────────────────────────────────
|
|
538
|
+
// Render
|
|
539
|
+
// ─────────────────────────────────────────────
|
|
540
|
+
|
|
541
|
+
return (
|
|
542
|
+
<div ref={editAreaRef} className={prefix('edit-area')}>
|
|
543
|
+
<AutoSizer>
|
|
544
|
+
{({ width, height }) => {
|
|
545
|
+
const totalHeight = calculateTotalHeight(editorData, rowHeight);
|
|
546
|
+
const heights = getRowHeights(editorData, rowHeight);
|
|
547
|
+
if (totalHeight < height) {
|
|
548
|
+
heights.push(height - totalHeight);
|
|
549
|
+
if (heightRef.current !== height && heightRef.current >= 0) {
|
|
550
|
+
setTimeout(() =>
|
|
551
|
+
gridRef.current?.recomputeGridSize({ rowIndex: heights.length - 1 }),
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
heightRef.current = height;
|
|
556
|
+
|
|
557
|
+
return (
|
|
558
|
+
<>
|
|
559
|
+
<Grid
|
|
560
|
+
columnCount={1}
|
|
561
|
+
rowCount={heights.length}
|
|
562
|
+
ref={gridRef}
|
|
563
|
+
cellRenderer={cellRenderer}
|
|
564
|
+
columnWidth={Math.max(scaleCount * scaleWidth + startLeft, width)}
|
|
565
|
+
width={width}
|
|
566
|
+
height={height}
|
|
567
|
+
rowHeight={({ index }) => heights[index] || rowHeight}
|
|
568
|
+
overscanRowCount={10}
|
|
569
|
+
overscanColumnCount={0}
|
|
570
|
+
onScroll={(param) => {
|
|
571
|
+
onScrollParamsRef.current = param;
|
|
572
|
+
onScroll(param);
|
|
573
|
+
}}
|
|
574
|
+
/>
|
|
575
|
+
{/* Row insertion line (row reorder) */}
|
|
576
|
+
<InsertionLine top={calculateRowAccumulatedHeight(dragState.insertionLine.index)} visible={dragState.insertionLine.visible} />
|
|
577
|
+
{/* Row drag preview (row reorder) */}
|
|
578
|
+
<DragPreview top={dragState.dragPreview.top} height={dragState.dragPreview.height} visible={dragState.dragPreview.visible} />
|
|
579
|
+
{/* Block ghost preview (cross-row block drag) */}
|
|
580
|
+
<CrossRowGhost
|
|
581
|
+
state={ghostState}
|
|
582
|
+
enableGhostPreview={enableGhostPreview}
|
|
583
|
+
getGhostPreview={getGhostPreview}
|
|
584
|
+
/>
|
|
585
|
+
</>
|
|
586
|
+
);
|
|
587
|
+
}}
|
|
588
|
+
</AutoSizer>
|
|
589
|
+
{dragLine && <DragLines scrollLeft={scrollLeft} {...dragLineData} />}
|
|
590
|
+
</div>
|
|
591
|
+
);
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
EditAreaInner.displayName = 'EditAreaInner';
|
|
595
|
+
|
|
596
|
+
// ─────────────────────────────────────────────
|
|
597
|
+
// Public export — wraps inner with provider
|
|
598
|
+
// ─────────────────────────────────────────────
|
|
599
|
+
|
|
600
|
+
export const EditArea = React.forwardRef<EditAreaState, EditAreaProps>((props, ref) => (
|
|
601
|
+
<CrossRowDragProvider>
|
|
602
|
+
<EditAreaInner {...props} ref={ref} />
|
|
603
|
+
</CrossRowDragProvider>
|
|
604
|
+
));
|
|
605
|
+
|
|
606
|
+
EditArea.displayName = 'EditArea';
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
.timeline-editor-edit-row-dragging {
|
|
2
|
+
opacity: 0.5;
|
|
3
|
+
}
|
|
4
|
+
.timeline-editor-edit-row {
|
|
5
|
+
background-repeat: no-repeat, repeat;
|
|
6
|
+
background-image: linear-gradient(#191b1d, #191b1d), linear-gradient(90deg, rgba(255, 255, 255, 0.08) 1px, transparent 0);
|
|
7
|
+
display: flex;
|
|
8
|
+
flex-direction: row;
|
|
9
|
+
box-sizing: border-box;
|
|
10
|
+
position: relative;
|
|
11
|
+
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
12
|
+
}
|
|
13
|
+
.timeline-editor-edit-row-drag-handle {
|
|
14
|
+
position: absolute;
|
|
15
|
+
left: 4px;
|
|
16
|
+
top: 50%;
|
|
17
|
+
transform: translateY(-50%);
|
|
18
|
+
width: 16px;
|
|
19
|
+
height: 16px;
|
|
20
|
+
cursor: grab;
|
|
21
|
+
display: flex;
|
|
22
|
+
align-items: center;
|
|
23
|
+
justify-content: center;
|
|
24
|
+
color: #666;
|
|
25
|
+
font-size: 12px;
|
|
26
|
+
user-select: none;
|
|
27
|
+
z-index: 10;
|
|
28
|
+
opacity: 0.6;
|
|
29
|
+
transition: opacity 0.2s ease;
|
|
30
|
+
}
|
|
31
|
+
.timeline-editor-edit-row-drag-handle:hover {
|
|
32
|
+
opacity: 1;
|
|
33
|
+
color: #999;
|
|
34
|
+
}
|
|
35
|
+
.timeline-editor-edit-row-drag-handle:active {
|
|
36
|
+
cursor: grabbing;
|
|
37
|
+
}
|
|
38
|
+
.timeline-editor-edit-row-dragged {
|
|
39
|
+
opacity: 0.5;
|
|
40
|
+
transform: scale(0.98);
|
|
41
|
+
pointer-events: none;
|
|
42
|
+
z-index: 1000;
|
|
43
|
+
position: relative;
|
|
44
|
+
}
|
|
45
|
+
.timeline-editor-edit-row-dragging {
|
|
46
|
+
cursor: grabbing;
|
|
47
|
+
}
|
|
48
|
+
.timeline-editor-edit-row-placeholder {
|
|
49
|
+
background: rgba(74, 144, 226, 0.1);
|
|
50
|
+
border: 2px dashed #4a90e2;
|
|
51
|
+
height: 4px;
|
|
52
|
+
min-height: 4px;
|
|
53
|
+
margin: 2px 0;
|
|
54
|
+
opacity: 0.8;
|
|
55
|
+
}
|
|
56
|
+
.timeline-editor-edit-row-placeholder .timeline-editor-edit-row-drag-handle,
|
|
57
|
+
.timeline-editor-edit-row-placeholder .timeline-editor-edit-action {
|
|
58
|
+
display: none;
|
|
59
|
+
}
|
|
60
|
+
.timeline-editor-edit-row::before {
|
|
61
|
+
content: '';
|
|
62
|
+
position: absolute;
|
|
63
|
+
left: 0;
|
|
64
|
+
right: 0;
|
|
65
|
+
height: 2px;
|
|
66
|
+
background: #4a90e2;
|
|
67
|
+
z-index: 100;
|
|
68
|
+
opacity: 0;
|
|
69
|
+
transition: opacity 0.2s ease;
|
|
70
|
+
}
|
|
71
|
+
.timeline-editor-edit-row[data-insert-position="top"]::before {
|
|
72
|
+
top: -1px;
|
|
73
|
+
opacity: 1;
|
|
74
|
+
}
|
|
75
|
+
.timeline-editor-edit-row[data-insert-position="bottom"]::before {
|
|
76
|
+
bottom: -1px;
|
|
77
|
+
opacity: 1;
|
|
78
|
+
}
|