react-timelane 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/static.yml +55 -0
- package/README.md +54 -0
- package/docs/README.md +54 -0
- package/docs/eslint.config.js +28 -0
- package/docs/index.html +12 -0
- package/docs/package-lock.json +5101 -0
- package/docs/package.json +35 -0
- package/docs/src/App.css +5 -0
- package/docs/src/App.tsx +59 -0
- package/docs/src/assets/react.svg +1 -0
- package/docs/src/components/AllocationComponent.tsx +82 -0
- package/docs/src/components/Timeline.tsx +183 -0
- package/docs/src/constants.ts +42 -0
- package/docs/src/hooks/useLocalStorage.ts +21 -0
- package/docs/src/main.tsx +9 -0
- package/docs/src/models/Allocation.ts +11 -0
- package/docs/src/models/AllocationId.ts +1 -0
- package/docs/src/models/Resource.ts +8 -0
- package/docs/src/models/ResourceId.ts +1 -0
- package/docs/src/vite-env.d.ts +1 -0
- package/docs/tsconfig.json +27 -0
- package/docs/vite.config.ts +8 -0
- package/eslint.config.js +28 -0
- package/index.html +13 -0
- package/package.json +43 -0
- package/src/components/Timeline.scss +297 -0
- package/src/components/TimelineAside.tsx +81 -0
- package/src/components/TimelineBackground.tsx +53 -0
- package/src/components/TimelineBody.tsx +54 -0
- package/src/components/TimelineHeader/DaysHeader.tsx +44 -0
- package/src/components/TimelineHeader/MonthsHeader.tsx +62 -0
- package/src/components/TimelineHeader/TimelineHeader.tsx +64 -0
- package/src/components/TimelineHeader/WeeksHeader.tsx +63 -0
- package/src/components/TimelineHeader/index.ts +9 -0
- package/src/components/TimelineHeader/renderingUtils.tsx +57 -0
- package/src/components/TimelineSelectionLayer.tsx +179 -0
- package/src/components/TimelineSettingsContext.tsx +27 -0
- package/src/components/TimelineSettingsProvider.tsx +24 -0
- package/src/components/TimelineWrapper.tsx +51 -0
- package/src/components/core/CoreItem/CoreItemComponent.tsx +69 -0
- package/src/components/core/CoreItem/DragResizeComponent.tsx +180 -0
- package/src/components/core/CoreSwimlane/AvailableSpaceIndicator.tsx +156 -0
- package/src/components/core/CoreSwimlane/CoreSwimlane.tsx +245 -0
- package/src/components/core/CoreSwimlane/DropPreview.tsx +30 -0
- package/src/components/core/CoreSwimlane/DropTarget.tsx +83 -0
- package/src/components/core/CoreSwimlane/OverlapIndicator.tsx +22 -0
- package/src/components/core/CoreSwimlane/utils.ts +375 -0
- package/src/components/core/utils.ts +154 -0
- package/src/components/layout/TimelineLayout.tsx +93 -0
- package/src/components/layout/layout.scss +107 -0
- package/src/global.d.ts +9 -0
- package/src/hooks/useScroll.tsx +71 -0
- package/src/hooks/useTimelineContext.tsx +6 -0
- package/src/index.ts +15 -0
- package/src/types/AvailableSpace.ts +6 -0
- package/src/types/CoreItem.ts +23 -0
- package/src/types/DateBounds.ts +4 -0
- package/src/types/Dimensions.ts +4 -0
- package/src/types/GrabInfo.ts +6 -0
- package/src/types/Grid.ts +6 -0
- package/src/types/ItemId.ts +1 -0
- package/src/types/OffsetBounds.ts +4 -0
- package/src/types/Pixels.ts +4 -0
- package/src/types/Position.ts +4 -0
- package/src/types/Rectangle.ts +6 -0
- package/src/types/SwimlaneId.ts +1 -0
- package/src/types/SwimlaneT.ts +6 -0
- package/src/types/TimeRange.ts +4 -0
- package/src/types/TimelineSettings.ts +11 -0
- package/src/types/index.ts +15 -0
- package/tsconfig.json +32 -0
- package/vite.config.ts +32 -0
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { max, min } from "date-fns";
|
|
2
|
+
import {
|
|
3
|
+
getItemDimensions,
|
|
4
|
+
getItemRectangle,
|
|
5
|
+
getDropTargetDimensions,
|
|
6
|
+
} from "../utils";
|
|
7
|
+
import {
|
|
8
|
+
AvailableSpace,
|
|
9
|
+
CoreItem,
|
|
10
|
+
DateBounds,
|
|
11
|
+
Dimensions,
|
|
12
|
+
GrabInfo,
|
|
13
|
+
Grid,
|
|
14
|
+
OffsetBounds,
|
|
15
|
+
Pixels,
|
|
16
|
+
Position,
|
|
17
|
+
Rectangle,
|
|
18
|
+
SwimlaneT,
|
|
19
|
+
TimeRange,
|
|
20
|
+
} from "../../../types";
|
|
21
|
+
|
|
22
|
+
export function getDropPreviewRectangle<S, T>(
|
|
23
|
+
swimlane: SwimlaneT,
|
|
24
|
+
items: CoreItem<T>[],
|
|
25
|
+
item: CoreItem<S>,
|
|
26
|
+
mousePosition: Position | null,
|
|
27
|
+
grabInfo: GrabInfo,
|
|
28
|
+
pixels: Pixels,
|
|
29
|
+
grid: Grid,
|
|
30
|
+
range: TimeRange,
|
|
31
|
+
allowOverlaps: boolean
|
|
32
|
+
): Rectangle | null {
|
|
33
|
+
if (mousePosition === null) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const dropTargetDimensions: Dimensions = getDropTargetDimensions(
|
|
38
|
+
swimlane,
|
|
39
|
+
pixels,
|
|
40
|
+
range
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const itemRectangles: Rectangle[] = items
|
|
44
|
+
.filter((a) => a.id !== item.id)
|
|
45
|
+
.map((a) => getItemRectangle(a, swimlane, range, pixels));
|
|
46
|
+
|
|
47
|
+
let dropPreviewRectangle: Rectangle | null = null;
|
|
48
|
+
|
|
49
|
+
dropPreviewRectangle = getRectangleUnderCursor(
|
|
50
|
+
swimlane,
|
|
51
|
+
item,
|
|
52
|
+
grabInfo,
|
|
53
|
+
pixels,
|
|
54
|
+
mousePosition
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
dropPreviewRectangle = fitToGrid(dropPreviewRectangle, grid);
|
|
58
|
+
|
|
59
|
+
dropPreviewRectangle = fitToDropTarget(
|
|
60
|
+
dropPreviewRectangle,
|
|
61
|
+
dropTargetDimensions
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
dropPreviewRectangle = getNearestDropDestinationWithoutOverlap(
|
|
65
|
+
dropPreviewRectangle,
|
|
66
|
+
itemRectangles,
|
|
67
|
+
dropTargetDimensions,
|
|
68
|
+
allowOverlaps
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return dropPreviewRectangle;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* computes rectangle for DropPreview that is under the cursor, in pixel coordinates relative to the current DropTarget
|
|
76
|
+
* @param dragData
|
|
77
|
+
* @param swimlane
|
|
78
|
+
* @param pixels
|
|
79
|
+
* @param mousePosition
|
|
80
|
+
* @returns
|
|
81
|
+
*/
|
|
82
|
+
function getRectangleUnderCursor<T>(
|
|
83
|
+
swimlane: SwimlaneT,
|
|
84
|
+
item: CoreItem<T>,
|
|
85
|
+
grabInfo: GrabInfo,
|
|
86
|
+
pixels: Pixels,
|
|
87
|
+
mousePosition: Position
|
|
88
|
+
): Rectangle {
|
|
89
|
+
const { width, height } = getItemDimensions(item, swimlane, pixels);
|
|
90
|
+
const x = mousePosition.x + width * grabInfo.relative.x;
|
|
91
|
+
const y = mousePosition.y + height * grabInfo.relative.y;
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
x,
|
|
95
|
+
y,
|
|
96
|
+
width,
|
|
97
|
+
height,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function fitToDropTarget(
|
|
102
|
+
rect: Rectangle,
|
|
103
|
+
dropTargetDimensions: Dimensions
|
|
104
|
+
): Rectangle {
|
|
105
|
+
// adjust coordinates to always be in DropTarget rectangle
|
|
106
|
+
|
|
107
|
+
const x = Math.max(
|
|
108
|
+
0,
|
|
109
|
+
Math.min(rect.x, dropTargetDimensions.width - rect.width)
|
|
110
|
+
);
|
|
111
|
+
const y = Math.max(
|
|
112
|
+
0,
|
|
113
|
+
Math.min(rect.y, dropTargetDimensions.height - rect.height)
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
...rect,
|
|
118
|
+
x,
|
|
119
|
+
y,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function fitToGrid(rect: Rectangle, grid: Grid | null | undefined): Rectangle {
|
|
124
|
+
const destination = {
|
|
125
|
+
...rect,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
if (grid && grid.x) {
|
|
129
|
+
destination.x = roundToNearest(destination.x, grid.x, grid.offsetX || 0);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (grid && grid.y) {
|
|
133
|
+
destination.y = roundToNearest(destination.y, grid.y, grid.offsetY || 0);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return destination;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function roundToNearest(
|
|
140
|
+
value: number,
|
|
141
|
+
interval: number,
|
|
142
|
+
offset: number
|
|
143
|
+
): number {
|
|
144
|
+
return Math.max(
|
|
145
|
+
offset,
|
|
146
|
+
offset + Math.round((value - offset) / interval) * interval
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getNearestDropDestinationWithoutOverlap(
|
|
151
|
+
item: Rectangle,
|
|
152
|
+
allItems: Rectangle[],
|
|
153
|
+
targetDimensions: Dimensions,
|
|
154
|
+
allowOverlaps: boolean
|
|
155
|
+
): Rectangle | null {
|
|
156
|
+
const newItem: Rectangle = {
|
|
157
|
+
...item,
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
if (!allowOverlaps) {
|
|
161
|
+
let isOverlapping = true;
|
|
162
|
+
|
|
163
|
+
while (isOverlapping) {
|
|
164
|
+
const firstOverlapping: Rectangle | undefined = allItems.find((other) => {
|
|
165
|
+
return doOverlap(newItem, other);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (firstOverlapping !== undefined) {
|
|
169
|
+
// move the element below the one it overlaps with
|
|
170
|
+
newItem.y = firstOverlapping.y + firstOverlapping.height;
|
|
171
|
+
} else {
|
|
172
|
+
isOverlapping = false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!isWithinTargetDimensions(newItem, targetDimensions)) {
|
|
178
|
+
return null;
|
|
179
|
+
} else {
|
|
180
|
+
return newItem;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Returns true if two rectangles (l1, r1) and (l2, r2) overlap
|
|
186
|
+
* @param el1
|
|
187
|
+
* @param el2
|
|
188
|
+
* @returns
|
|
189
|
+
*/
|
|
190
|
+
export function doOverlap(el1: Rectangle, el2: Rectangle): boolean {
|
|
191
|
+
if (el1.x >= el2.x + el2.width || el2.x >= el1.x + el1.width) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (el1.y >= el2.y + el2.height || el2.y >= el1.y + el1.height) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function getOverlap(
|
|
203
|
+
rect1: Rectangle,
|
|
204
|
+
rect2: Rectangle
|
|
205
|
+
): Rectangle | null {
|
|
206
|
+
// Calculate the coordinates of the overlapping region
|
|
207
|
+
const overlapX1 = Math.max(rect1.x, rect2.x);
|
|
208
|
+
const overlapY1 = Math.max(rect1.y, rect2.y);
|
|
209
|
+
const overlapX2 = Math.min(rect1.x + rect1.width, rect2.x + rect2.width);
|
|
210
|
+
const overlapY2 = Math.min(rect1.y + rect1.height, rect2.y + rect2.height);
|
|
211
|
+
|
|
212
|
+
// Check if there is an overlap
|
|
213
|
+
if (overlapX1 < overlapX2 && overlapY1 < overlapY2) {
|
|
214
|
+
return {
|
|
215
|
+
x: overlapX1,
|
|
216
|
+
y: overlapY1,
|
|
217
|
+
width: overlapX2 - overlapX1,
|
|
218
|
+
height: overlapY2 - overlapY1,
|
|
219
|
+
};
|
|
220
|
+
} else {
|
|
221
|
+
// No overlap
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function itemsDoOverlap<T>(a: CoreItem<T>, b: CoreItem<T>): boolean {
|
|
227
|
+
if (
|
|
228
|
+
a.start >= b.end ||
|
|
229
|
+
b.start >= a.end ||
|
|
230
|
+
a.offset >= b.offset + b.size ||
|
|
231
|
+
b.offset >= a.offset + a.size
|
|
232
|
+
) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function isWithinTargetDimensions(
|
|
240
|
+
item: Rectangle,
|
|
241
|
+
targetDimensions: Dimensions
|
|
242
|
+
): boolean {
|
|
243
|
+
return (
|
|
244
|
+
item.x >= 0 &&
|
|
245
|
+
item.y >= 0 &&
|
|
246
|
+
item.x + item.width <= targetDimensions.width &&
|
|
247
|
+
item.y + item.height <= targetDimensions.height
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function getAvailableSpace<T>(
|
|
252
|
+
clickedDate: Date,
|
|
253
|
+
clickedOffset: number,
|
|
254
|
+
swimlane: SwimlaneT,
|
|
255
|
+
items: CoreItem<T>[],
|
|
256
|
+
range: TimeRange
|
|
257
|
+
): AvailableSpace | null {
|
|
258
|
+
const offsetBounds = getOffsetBounds(
|
|
259
|
+
clickedDate,
|
|
260
|
+
clickedOffset,
|
|
261
|
+
swimlane.capacity,
|
|
262
|
+
items
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
if (offsetBounds) {
|
|
266
|
+
const dateBounds = getDateBounds(
|
|
267
|
+
clickedDate,
|
|
268
|
+
range.start,
|
|
269
|
+
range.end,
|
|
270
|
+
items,
|
|
271
|
+
offsetBounds
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
if (dateBounds) {
|
|
275
|
+
return {
|
|
276
|
+
start: dateBounds.lower,
|
|
277
|
+
end: dateBounds.upper,
|
|
278
|
+
minOffset: offsetBounds.lower,
|
|
279
|
+
maxOffset: offsetBounds.upper,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function getOffsetBounds<T>(
|
|
288
|
+
clickedDate: Date,
|
|
289
|
+
clickedOffset: number,
|
|
290
|
+
maxUpperBound: number,
|
|
291
|
+
items: CoreItem<T>[]
|
|
292
|
+
): null | OffsetBounds {
|
|
293
|
+
const allocationsAtDate: CoreItem<T>[] = items.filter((a) => {
|
|
294
|
+
return a.start <= clickedDate && a.end >= clickedDate;
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// 1. check if there is an allocation at the place where the click occurred
|
|
298
|
+
const allocationAtClick: CoreItem<T> | undefined = allocationsAtDate.find(
|
|
299
|
+
(a) => a.offset <= clickedOffset && a.offset + a.size >= clickedOffset
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
if (allocationAtClick) {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
// part remaining allocations by whether they are above or below the click
|
|
306
|
+
const allocationsAboveClick = allocationsAtDate.filter((a) => {
|
|
307
|
+
return a.offset + a.size <= clickedOffset;
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const allocationsBelowClick = allocationsAtDate.filter((a) => {
|
|
311
|
+
return a.offset >= clickedOffset;
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// the minimum available space in offset coordinates is the
|
|
315
|
+
// maximum (offset + size) of all allocations above
|
|
316
|
+
const lowerOffsetBound =
|
|
317
|
+
allocationsAboveClick.length > 0
|
|
318
|
+
? Math.max(...allocationsAboveClick.map((a) => a.offset + a.size))
|
|
319
|
+
: 0;
|
|
320
|
+
|
|
321
|
+
const upperOffsetBound =
|
|
322
|
+
allocationsBelowClick.length > 0
|
|
323
|
+
? Math.min(...allocationsBelowClick.map((a) => a.offset))
|
|
324
|
+
: maxUpperBound;
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
lower: lowerOffsetBound,
|
|
328
|
+
upper: upperOffsetBound,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function getDateBounds<T>(
|
|
333
|
+
clickedDate: Date,
|
|
334
|
+
minLowerBound: Date,
|
|
335
|
+
maxUpperBound: Date,
|
|
336
|
+
items: CoreItem<T>[],
|
|
337
|
+
offsetBounds: OffsetBounds
|
|
338
|
+
): DateBounds | null {
|
|
339
|
+
const dummyItem: CoreItem<null> = {
|
|
340
|
+
id: -1,
|
|
341
|
+
swimlaneId: -1,
|
|
342
|
+
start: minLowerBound,
|
|
343
|
+
end: maxUpperBound,
|
|
344
|
+
offset: offsetBounds.lower,
|
|
345
|
+
size: offsetBounds.upper - offsetBounds.lower,
|
|
346
|
+
payload: null,
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const overlappingItems: CoreItem<T>[] = items.filter((a) =>
|
|
350
|
+
itemsDoOverlap(a, dummyItem)
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
if (overlappingItems.length === 0) {
|
|
354
|
+
return {
|
|
355
|
+
lower: minLowerBound,
|
|
356
|
+
upper: maxUpperBound,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const itemsBefore = overlappingItems.filter((a) => a.end <= clickedDate);
|
|
361
|
+
|
|
362
|
+
const itemsAfter = overlappingItems.filter((a) => a.start >= clickedDate);
|
|
363
|
+
|
|
364
|
+
// the minimum available space in offset coordinates is the
|
|
365
|
+
// maximum (offset + size) of all allocations above
|
|
366
|
+
const lowerDateBound =
|
|
367
|
+
itemsBefore.length > 0 ? max(itemsBefore.map((a) => a.end)) : minLowerBound;
|
|
368
|
+
const upperDateBound =
|
|
369
|
+
itemsAfter.length > 0 ? min(itemsAfter.map((a) => a.start)) : maxUpperBound;
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
lower: lowerDateBound,
|
|
373
|
+
upper: upperDateBound,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DragLocationHistory,
|
|
3
|
+
ElementDragPayload,
|
|
4
|
+
} from "@atlaskit/pragmatic-drag-and-drop/dist/types/internal-types";
|
|
5
|
+
import { addDays, differenceInCalendarDays, setHours } from "date-fns";
|
|
6
|
+
import {
|
|
7
|
+
CoreItem,
|
|
8
|
+
Dimensions,
|
|
9
|
+
Pixels,
|
|
10
|
+
Position,
|
|
11
|
+
Rectangle,
|
|
12
|
+
SwimlaneT,
|
|
13
|
+
TimeRange,
|
|
14
|
+
} from "../../types";
|
|
15
|
+
|
|
16
|
+
export function getDropTargetDimensions(
|
|
17
|
+
swimlane: SwimlaneT,
|
|
18
|
+
pixels: Pixels,
|
|
19
|
+
range: TimeRange
|
|
20
|
+
): Dimensions {
|
|
21
|
+
const width = getDropTargetWidth(swimlane, pixels, range);
|
|
22
|
+
const height = getDropTargetHeight(swimlane, pixels);
|
|
23
|
+
|
|
24
|
+
return { width, height };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getDropTargetHeight(
|
|
28
|
+
swimlane: SwimlaneT,
|
|
29
|
+
pixels: Pixels
|
|
30
|
+
): number {
|
|
31
|
+
return pixels.pixelsPerResource;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getDropTargetWidth(
|
|
35
|
+
swimlane: SwimlaneT,
|
|
36
|
+
pixels: Pixels,
|
|
37
|
+
range: TimeRange
|
|
38
|
+
): number {
|
|
39
|
+
return Math.abs(
|
|
40
|
+
(differenceInCalendarDays(range.end, range.start) + 1) * pixels.pixelsPerDay
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getItemRectangle<T>(
|
|
45
|
+
item: CoreItem<T>,
|
|
46
|
+
swimlane: SwimlaneT,
|
|
47
|
+
range: TimeRange,
|
|
48
|
+
pixels: Pixels
|
|
49
|
+
): Rectangle {
|
|
50
|
+
const dimensions = getItemDimensions(item, swimlane, pixels);
|
|
51
|
+
const position = getItemPosition(item, swimlane, range.start, pixels);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
...dimensions,
|
|
55
|
+
...position,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getItemDimensions<T>(
|
|
60
|
+
item: CoreItem<T>,
|
|
61
|
+
swimlane: SwimlaneT,
|
|
62
|
+
pixels: Pixels
|
|
63
|
+
): Dimensions {
|
|
64
|
+
const width =
|
|
65
|
+
differenceInCalendarDays(item.end, item.start) * pixels.pixelsPerDay;
|
|
66
|
+
|
|
67
|
+
const height = (item.size / swimlane.capacity) * pixels.pixelsPerResource;
|
|
68
|
+
|
|
69
|
+
return { width, height };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function dateToPixel(date: Date, start: Date, pixels: Pixels) {
|
|
73
|
+
return (
|
|
74
|
+
differenceInCalendarDays(date, start) * pixels.pixelsPerDay +
|
|
75
|
+
pixels.pixelsPerDay / 2
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function offsetToPixel(
|
|
80
|
+
offset: number,
|
|
81
|
+
capacity: number,
|
|
82
|
+
pixels: Pixels
|
|
83
|
+
) {
|
|
84
|
+
return (offset / capacity) * pixels.pixelsPerResource;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getItemPosition<T>(
|
|
88
|
+
item: CoreItem<T>,
|
|
89
|
+
swimlane: SwimlaneT,
|
|
90
|
+
start: Date,
|
|
91
|
+
pixels: Pixels
|
|
92
|
+
): Position {
|
|
93
|
+
const x = dateToPixel(item.start, start, pixels);
|
|
94
|
+
const y = offsetToPixel(item.offset, swimlane.capacity, pixels);
|
|
95
|
+
|
|
96
|
+
return { x, y };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function getGrabPosition(
|
|
100
|
+
source: ElementDragPayload,
|
|
101
|
+
location: DragLocationHistory
|
|
102
|
+
) {
|
|
103
|
+
const sourceRect = source.element.getBoundingClientRect();
|
|
104
|
+
|
|
105
|
+
const grabPosition: Position = {
|
|
106
|
+
x: sourceRect.x - location.initial.input.pageX,
|
|
107
|
+
y: sourceRect.y - location.initial.input.pageY,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const relativeGrabPosition: Position = {
|
|
111
|
+
x: grabPosition.x / sourceRect.width,
|
|
112
|
+
y: grabPosition.y / sourceRect.height,
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
absolute: grabPosition,
|
|
117
|
+
relative: relativeGrabPosition,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function getUpdatedItem<T>(
|
|
122
|
+
oldItem: CoreItem<T>,
|
|
123
|
+
swimlane: SwimlaneT,
|
|
124
|
+
dropPreviewRect: Rectangle,
|
|
125
|
+
pixels: Pixels,
|
|
126
|
+
range: TimeRange
|
|
127
|
+
): CoreItem<T> {
|
|
128
|
+
// convert drop preview position to item
|
|
129
|
+
return {
|
|
130
|
+
id: oldItem.id,
|
|
131
|
+
swimlaneId: swimlane.id,
|
|
132
|
+
|
|
133
|
+
start: setHours(
|
|
134
|
+
addDays(range.start, Math.floor(dropPreviewRect.x / pixels.pixelsPerDay)),
|
|
135
|
+
12
|
|
136
|
+
),
|
|
137
|
+
end: setHours(
|
|
138
|
+
addDays(
|
|
139
|
+
range.start,
|
|
140
|
+
Math.floor(
|
|
141
|
+
(dropPreviewRect.x + dropPreviewRect.width) / pixels.pixelsPerDay
|
|
142
|
+
)
|
|
143
|
+
),
|
|
144
|
+
12
|
|
145
|
+
),
|
|
146
|
+
offset: Math.floor(
|
|
147
|
+
(dropPreviewRect.y / pixels.pixelsPerResource) * swimlane.capacity
|
|
148
|
+
),
|
|
149
|
+
size: Math.floor(
|
|
150
|
+
(dropPreviewRect.height / pixels.pixelsPerResource) * swimlane.capacity
|
|
151
|
+
),
|
|
152
|
+
payload: oldItem.payload,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { PropsWithChildren, useRef } from "react";
|
|
2
|
+
import "./layout.scss";
|
|
3
|
+
|
|
4
|
+
function TimelineLayout({ children }: PropsWithChildren<{}>) {
|
|
5
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
6
|
+
return (
|
|
7
|
+
<div
|
|
8
|
+
className="timeline-layout"
|
|
9
|
+
onWheel={(e) => {
|
|
10
|
+
if (ref.current && (e.ctrlKey || e.shiftKey)) {
|
|
11
|
+
ref.current.scrollLeft += e.deltaY;
|
|
12
|
+
e.preventDefault();
|
|
13
|
+
}
|
|
14
|
+
}}
|
|
15
|
+
ref={ref}
|
|
16
|
+
>
|
|
17
|
+
<div className="timeline-layout-inner">{children}</div>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
function TimelineLayoutHeader({ children }: PropsWithChildren<{}>) {
|
|
22
|
+
return <div className="timeline-layout-header">{children}</div>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function TimelineLayoutBackground({ children }: PropsWithChildren<{}>) {
|
|
26
|
+
return <div className="timeline-layout-background">{children}</div>;
|
|
27
|
+
}
|
|
28
|
+
function TimelineLayoutBody({ children }: PropsWithChildren<{}>) {
|
|
29
|
+
return <div className="timeline-layout-body">{children}</div>;
|
|
30
|
+
}
|
|
31
|
+
function TimelineLayoutFooter({ children }: PropsWithChildren<{}>) {
|
|
32
|
+
return <div className="timeline-layout-footer">{children}</div>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface TimelineLayoutAsideProps {
|
|
36
|
+
side?: "left" | "right";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function TimelineLayoutAside({
|
|
40
|
+
side = "left",
|
|
41
|
+
children,
|
|
42
|
+
}: PropsWithChildren<TimelineLayoutAsideProps>) {
|
|
43
|
+
return (
|
|
44
|
+
<div
|
|
45
|
+
className={`timeline-layout-aside ${
|
|
46
|
+
side == "left"
|
|
47
|
+
? "timeline-layout-aside-left"
|
|
48
|
+
: "timeline-layout-aside-right"
|
|
49
|
+
}`}
|
|
50
|
+
>
|
|
51
|
+
{children}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface TimelineLayoutCornerProps {
|
|
57
|
+
corner?: "top left" | "top right" | "bottom left" | "bottom right";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function TimelineLayoutCorner({
|
|
61
|
+
corner = "top left",
|
|
62
|
+
children,
|
|
63
|
+
}: PropsWithChildren<TimelineLayoutCornerProps>) {
|
|
64
|
+
let className = "";
|
|
65
|
+
|
|
66
|
+
switch (corner) {
|
|
67
|
+
case "top left":
|
|
68
|
+
className = "timeline-layout-corner-top-left";
|
|
69
|
+
break;
|
|
70
|
+
case "top right":
|
|
71
|
+
className = "timeline-layout-corner-top-right";
|
|
72
|
+
break;
|
|
73
|
+
case "bottom left":
|
|
74
|
+
className = "timeline-layout-corner-bottom-left";
|
|
75
|
+
break;
|
|
76
|
+
case "bottom right":
|
|
77
|
+
className = "timeline-layout-corner-bottom-right";
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className={`timeline-layout-corner ${className}`}>{children}</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
TimelineLayout.Header = TimelineLayoutHeader;
|
|
87
|
+
TimelineLayout.Body = TimelineLayoutBody;
|
|
88
|
+
TimelineLayout.Background = TimelineLayoutBackground;
|
|
89
|
+
TimelineLayout.Footer = TimelineLayoutFooter;
|
|
90
|
+
TimelineLayout.Aside = TimelineLayoutAside;
|
|
91
|
+
TimelineLayout.Corner = TimelineLayoutCorner;
|
|
92
|
+
|
|
93
|
+
export default TimelineLayout;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
.timeline-layout {
|
|
2
|
+
position: relative;
|
|
3
|
+
width: 100%;
|
|
4
|
+
height: 100%;
|
|
5
|
+
overflow: auto;
|
|
6
|
+
|
|
7
|
+
.timeline-layout-inner {
|
|
8
|
+
position: relative;
|
|
9
|
+
width: fit-content;
|
|
10
|
+
height: fit-content;
|
|
11
|
+
display: grid;
|
|
12
|
+
|
|
13
|
+
grid-template-areas:
|
|
14
|
+
"corner-tl header corner-tr"
|
|
15
|
+
"aside-l body aside-r"
|
|
16
|
+
"corner-bl footer corner-br";
|
|
17
|
+
|
|
18
|
+
.timeline-layout-header,
|
|
19
|
+
.timeline-layout-footer,
|
|
20
|
+
.timeline-layout-aside,
|
|
21
|
+
.timeline-layout-corner {
|
|
22
|
+
background: white;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.timeline-layout-header {
|
|
26
|
+
grid-area: header;
|
|
27
|
+
|
|
28
|
+
position: sticky;
|
|
29
|
+
position: -webkit-sticky;
|
|
30
|
+
top: 0;
|
|
31
|
+
z-index: 101;
|
|
32
|
+
overflow: hidden;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
.timeline-layout-body {
|
|
36
|
+
grid-area: body;
|
|
37
|
+
z-index: 100;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.timeline-layout-background {
|
|
41
|
+
grid-area: body;
|
|
42
|
+
z-index: -1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.timeline-layout-footer {
|
|
46
|
+
grid-area: footer;
|
|
47
|
+
|
|
48
|
+
position: sticky;
|
|
49
|
+
position: -webkit-sticky;
|
|
50
|
+
bottom: 0;
|
|
51
|
+
z-index: 101;
|
|
52
|
+
border-top: 1px solid lightgray;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.timeline-layout-aside {
|
|
56
|
+
grid-area: aside-l;
|
|
57
|
+
|
|
58
|
+
position: sticky;
|
|
59
|
+
position: -webkit-sticky;
|
|
60
|
+
left: 0;
|
|
61
|
+
z-index: 101;
|
|
62
|
+
|
|
63
|
+
&.timeline-layout-aside-right {
|
|
64
|
+
grid-area: aside-r;
|
|
65
|
+
|
|
66
|
+
right: 0;
|
|
67
|
+
left: initial;
|
|
68
|
+
border-left: 1px solid lightgray;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.timeline-layout-corner {
|
|
73
|
+
position: sticky;
|
|
74
|
+
position: -webkit-sticky;
|
|
75
|
+
z-index: 102;
|
|
76
|
+
|
|
77
|
+
&.timeline-layout-corner-top-left {
|
|
78
|
+
grid-area: corner-tl;
|
|
79
|
+
top: 0;
|
|
80
|
+
left: 0;
|
|
81
|
+
border-bottom: 1px solid lightgray;
|
|
82
|
+
border-right: 1px solid lightgray;
|
|
83
|
+
}
|
|
84
|
+
&.timeline-layout-corner-top-right {
|
|
85
|
+
grid-area: corner-tr;
|
|
86
|
+
top: 0;
|
|
87
|
+
right: 0;
|
|
88
|
+
border-bottom: 1px solid lightgray;
|
|
89
|
+
border-left: 1px solid lightgray;
|
|
90
|
+
}
|
|
91
|
+
&.timeline-layout-corner-bottom-left {
|
|
92
|
+
grid-area: corner-bl;
|
|
93
|
+
bottom: 0;
|
|
94
|
+
left: 0;
|
|
95
|
+
border-top: 1px solid lightgray;
|
|
96
|
+
border-right: 1px solid lightgray;
|
|
97
|
+
}
|
|
98
|
+
&.timeline-layout-corner-bottom-right {
|
|
99
|
+
grid-area: corner-br;
|
|
100
|
+
bottom: 0;
|
|
101
|
+
right: 0;
|
|
102
|
+
border-top: 1px solid lightgray;
|
|
103
|
+
border-left: 1px solid lightgray;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|