@luckydye/calendar 1.3.0 → 1.3.2
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/calendar.js +2198 -1539
- package/package.json +2 -2
- package/src/CalDAVConfig.ts +50 -1
- package/src/CalDAVSource.ts +52 -19
- package/src/CalendarIntegration.ts +2 -2
- package/src/CalendarInternal.ts +10 -4
- package/src/CalendarLayer.ts +28 -0
- package/src/CalendarView.ts +517 -1146
- package/src/DescriptionSanitizer.ts +10 -0
- package/src/GoogleCalendarSource.ts +27 -1
- package/src/ICal.ts +7 -2
- package/src/InMemorySource.ts +6 -6
- package/src/IndexedDBStorage.ts +6 -0
- package/src/InhouseBookingSource.ts +2 -1
- package/src/Keybinds.ts +3 -18
- package/src/StatusBar.ts +11 -0
- package/src/Theme.ts +4 -4
- package/src/TimeseriesJson.ts +114 -0
- package/src/app.ts +199 -48
- package/src/layers/EventsLayer.ts +958 -0
- package/src/layers/GridLayer.ts +296 -0
- package/src/layers/TimeseriesHeatmapLayer.ts +132 -0
- package/src/lib.ts +1 -0
|
@@ -0,0 +1,958 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CalendarEvent,
|
|
3
|
+
EventSegment,
|
|
4
|
+
WeekInfo,
|
|
5
|
+
} from "../CalendarInternal.js";
|
|
6
|
+
import { TIME_SCALE_DAY_HEIGHT, type CalendarLayer, type LayerContext } from "../CalendarLayer.js";
|
|
7
|
+
import { hexToRgb, rgbToHsl } from "../Color.js";
|
|
8
|
+
|
|
9
|
+
const MIN_EVENT_HEIGHT = 20;
|
|
10
|
+
|
|
11
|
+
export interface EventRect {
|
|
12
|
+
event: CalendarEvent;
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface EventsState {
|
|
20
|
+
events: CalendarEvent[];
|
|
21
|
+
hoveredEventId: string | null;
|
|
22
|
+
isEventSelected: (event: CalendarEvent) => boolean;
|
|
23
|
+
shouldRenderEventWithStripes: (event: CalendarEvent) => boolean;
|
|
24
|
+
getStripePatternCanvas: () => HTMLCanvasElement | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createEventsLayer(
|
|
28
|
+
state: EventsState,
|
|
29
|
+
): CalendarLayer & { eventRects: EventRect[] } {
|
|
30
|
+
const layer = {
|
|
31
|
+
name: "events",
|
|
32
|
+
enabled: true,
|
|
33
|
+
eventRects: [] as EventRect[],
|
|
34
|
+
render(lc: LayerContext): void {
|
|
35
|
+
const {
|
|
36
|
+
ctx,
|
|
37
|
+
width,
|
|
38
|
+
height,
|
|
39
|
+
scrollTop,
|
|
40
|
+
dayWidth,
|
|
41
|
+
dayHeight,
|
|
42
|
+
leftGutterWidth,
|
|
43
|
+
visibleWeeks,
|
|
44
|
+
allWeeks,
|
|
45
|
+
fontFamily,
|
|
46
|
+
styles,
|
|
47
|
+
getDayVisualPosition,
|
|
48
|
+
} = lc;
|
|
49
|
+
|
|
50
|
+
const events = state.events;
|
|
51
|
+
const viewportBottom = scrollTop + height;
|
|
52
|
+
const showTimeScale = dayHeight >= TIME_SCALE_DAY_HEIGHT;
|
|
53
|
+
|
|
54
|
+
layer.eventRects = [];
|
|
55
|
+
|
|
56
|
+
if (visibleWeeks.length === 0) return;
|
|
57
|
+
|
|
58
|
+
const firstVisibleWeek = visibleWeeks[0]!;
|
|
59
|
+
const lastVisibleWeek =
|
|
60
|
+
visibleWeeks[visibleWeeks.length - 1]!;
|
|
61
|
+
const firstVisibleDay = firstVisibleWeek.days[0]!;
|
|
62
|
+
const lastVisibleDay = lastVisibleWeek.days[6]!;
|
|
63
|
+
const visibleStartTime = firstVisibleDay.getTime();
|
|
64
|
+
const visibleEndTime =
|
|
65
|
+
lastVisibleDay.getTime() + 86400000 - 1;
|
|
66
|
+
|
|
67
|
+
const dayOccupiedRows = new Map<string, Set<number>>();
|
|
68
|
+
const eventRowIndex = new Map<string, number>();
|
|
69
|
+
|
|
70
|
+
const segments: EventSegment[] = [];
|
|
71
|
+
const allDaySegments: EventSegment[] = [];
|
|
72
|
+
const timedSegments: EventSegment[] = [];
|
|
73
|
+
|
|
74
|
+
for (const event of events) {
|
|
75
|
+
if (event.visualStyle === "heatmap") continue;
|
|
76
|
+
const eventStartTime = event.start.getTime();
|
|
77
|
+
const eventEndTime = event.end.getTime();
|
|
78
|
+
|
|
79
|
+
if (
|
|
80
|
+
eventEndTime < visibleStartTime ||
|
|
81
|
+
eventStartTime > visibleEndTime
|
|
82
|
+
)
|
|
83
|
+
continue;
|
|
84
|
+
|
|
85
|
+
const eventWeeks: {
|
|
86
|
+
weekIndex: number;
|
|
87
|
+
week: WeekInfo;
|
|
88
|
+
}[] = [];
|
|
89
|
+
|
|
90
|
+
for (const week of visibleWeeks) {
|
|
91
|
+
const weekStartDay = week.days[0];
|
|
92
|
+
const weekEndDay = week.days[6];
|
|
93
|
+
if (!weekStartDay || !weekEndDay) continue;
|
|
94
|
+
|
|
95
|
+
const weekStart = weekStartDay.getTime();
|
|
96
|
+
const weekEnd = weekEndDay.getTime() + 86399999;
|
|
97
|
+
|
|
98
|
+
if (
|
|
99
|
+
eventEndTime >= weekStart &&
|
|
100
|
+
eventStartTime <= weekEnd
|
|
101
|
+
) {
|
|
102
|
+
const weekIndex = allWeeks.indexOf(week);
|
|
103
|
+
eventWeeks.push({ weekIndex, week });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const totalWeeks = eventWeeks.length;
|
|
108
|
+
const isAllDay = event.isAllDay === true;
|
|
109
|
+
|
|
110
|
+
for (let i = 0; i < eventWeeks.length; i++) {
|
|
111
|
+
const { weekIndex, week } = eventWeeks[i]!;
|
|
112
|
+
|
|
113
|
+
const isStart = i === 0;
|
|
114
|
+
const isEnd = i === eventWeeks.length - 1;
|
|
115
|
+
|
|
116
|
+
let startDayIndex = 0;
|
|
117
|
+
let endDayIndex = 6;
|
|
118
|
+
|
|
119
|
+
if (isStart) {
|
|
120
|
+
startDayIndex = getDayIndexInWeek(
|
|
121
|
+
week,
|
|
122
|
+
event.start,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
if (isEnd) {
|
|
126
|
+
const effectiveEnd = isAllDay
|
|
127
|
+
? new Date(event.end.getTime() - 1)
|
|
128
|
+
: event.end;
|
|
129
|
+
endDayIndex = getDayIndexInWeek(
|
|
130
|
+
week,
|
|
131
|
+
effectiveEnd,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const segment = {
|
|
136
|
+
event,
|
|
137
|
+
weekIndex,
|
|
138
|
+
week,
|
|
139
|
+
startDayIndex,
|
|
140
|
+
endDayIndex,
|
|
141
|
+
isStart,
|
|
142
|
+
isEnd,
|
|
143
|
+
totalWeeks,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
if (isAllDay) {
|
|
147
|
+
allDaySegments.push(segment);
|
|
148
|
+
} else {
|
|
149
|
+
timedSegments.push(segment);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const renderedTimedSegments = showTimeScale
|
|
155
|
+
? timedSegments.flatMap((seg) => {
|
|
156
|
+
if (seg.startDayIndex === seg.endDayIndex)
|
|
157
|
+
return [seg];
|
|
158
|
+
const result = [];
|
|
159
|
+
for (
|
|
160
|
+
let d = seg.startDayIndex;
|
|
161
|
+
d <= seg.endDayIndex;
|
|
162
|
+
d++
|
|
163
|
+
) {
|
|
164
|
+
result.push({
|
|
165
|
+
...seg,
|
|
166
|
+
startDayIndex: d,
|
|
167
|
+
endDayIndex: d,
|
|
168
|
+
isStart:
|
|
169
|
+
d === seg.startDayIndex && seg.isStart,
|
|
170
|
+
isEnd: d === seg.endDayIndex && seg.isEnd,
|
|
171
|
+
totalWeeks: 1,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return result;
|
|
175
|
+
})
|
|
176
|
+
: timedSegments;
|
|
177
|
+
|
|
178
|
+
segments.push(...allDaySegments, ...renderedTimedSegments);
|
|
179
|
+
|
|
180
|
+
// Calculate columns for overlapping events in time scale mode
|
|
181
|
+
interface EventLayout {
|
|
182
|
+
segment: EventSegment;
|
|
183
|
+
yStart: number;
|
|
184
|
+
yEnd: number;
|
|
185
|
+
column: number;
|
|
186
|
+
totalColumns: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const segmentsByWeekDay = new Map<
|
|
190
|
+
string,
|
|
191
|
+
EventSegment[]
|
|
192
|
+
>();
|
|
193
|
+
for (const segment of segments) {
|
|
194
|
+
const allDay = segment.event.isAllDay === true;
|
|
195
|
+
if (showTimeScale && !allDay) {
|
|
196
|
+
for (
|
|
197
|
+
let dayIdx = segment.startDayIndex;
|
|
198
|
+
dayIdx <= segment.endDayIndex;
|
|
199
|
+
dayIdx++
|
|
200
|
+
) {
|
|
201
|
+
const key = `${segment.weekIndex}-${dayIdx}`;
|
|
202
|
+
if (!segmentsByWeekDay.has(key)) {
|
|
203
|
+
segmentsByWeekDay.set(key, []);
|
|
204
|
+
}
|
|
205
|
+
segmentsByWeekDay.get(key)!.push(segment);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const segmentColumns = new Map<
|
|
211
|
+
string,
|
|
212
|
+
{ column: number; totalColumns: number }
|
|
213
|
+
>();
|
|
214
|
+
|
|
215
|
+
for (const [dayKey, daySegments] of segmentsByWeekDay) {
|
|
216
|
+
const [weekIndexStr, dayIndexStr] = dayKey.split("-");
|
|
217
|
+
const dayIndex = parseInt(dayIndexStr!);
|
|
218
|
+
const weekIndex = parseInt(weekIndexStr!);
|
|
219
|
+
|
|
220
|
+
interface SegmentTime {
|
|
221
|
+
segment: EventSegment;
|
|
222
|
+
startMinutes: number;
|
|
223
|
+
endMinutes: number;
|
|
224
|
+
}
|
|
225
|
+
const segmentTimes: SegmentTime[] = [];
|
|
226
|
+
|
|
227
|
+
for (const seg of daySegments) {
|
|
228
|
+
const week = allWeeks[weekIndex];
|
|
229
|
+
if (!week) continue;
|
|
230
|
+
|
|
231
|
+
const dayStartTime = new Date(
|
|
232
|
+
week.days[dayIndex]!,
|
|
233
|
+
).setHours(0, 0, 0, 0);
|
|
234
|
+
const dayEndTime = new Date(
|
|
235
|
+
week.days[dayIndex]!,
|
|
236
|
+
).setHours(23, 59, 59, 999);
|
|
237
|
+
const eventStartTime = seg.event.start.getTime();
|
|
238
|
+
const eventEndTime =
|
|
239
|
+
seg.event.end.getTime() - 60000;
|
|
240
|
+
|
|
241
|
+
const effectiveStartTime = Math.max(
|
|
242
|
+
eventStartTime,
|
|
243
|
+
dayStartTime,
|
|
244
|
+
);
|
|
245
|
+
const effectiveEndTime = Math.min(
|
|
246
|
+
eventEndTime,
|
|
247
|
+
dayEndTime,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const effectiveStart = new Date(effectiveStartTime);
|
|
251
|
+
const effectiveEnd = new Date(effectiveEndTime);
|
|
252
|
+
|
|
253
|
+
const startMinutes =
|
|
254
|
+
effectiveStart.getHours() * 60 +
|
|
255
|
+
effectiveStart.getMinutes();
|
|
256
|
+
const endMinutes =
|
|
257
|
+
effectiveEnd.getHours() * 60 +
|
|
258
|
+
effectiveEnd.getMinutes();
|
|
259
|
+
|
|
260
|
+
segmentTimes.push({
|
|
261
|
+
segment: seg,
|
|
262
|
+
startMinutes,
|
|
263
|
+
endMinutes,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
segmentTimes.sort((a, b) => {
|
|
268
|
+
if (a.startMinutes !== b.startMinutes)
|
|
269
|
+
return a.startMinutes - b.startMinutes;
|
|
270
|
+
return (
|
|
271
|
+
b.endMinutes -
|
|
272
|
+
b.startMinutes -
|
|
273
|
+
(a.endMinutes - a.startMinutes)
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const columns: { endMinutes: number }[] = [];
|
|
278
|
+
|
|
279
|
+
for (const st of segmentTimes) {
|
|
280
|
+
let columnIndex = 0;
|
|
281
|
+
for (
|
|
282
|
+
;
|
|
283
|
+
columnIndex < columns.length;
|
|
284
|
+
columnIndex++
|
|
285
|
+
) {
|
|
286
|
+
if (
|
|
287
|
+
columns[columnIndex]!.endMinutes <=
|
|
288
|
+
st.startMinutes
|
|
289
|
+
) {
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (columnIndex === columns.length) {
|
|
295
|
+
columns.push({ endMinutes: st.endMinutes });
|
|
296
|
+
} else {
|
|
297
|
+
columns[columnIndex]!.endMinutes =
|
|
298
|
+
st.endMinutes;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const segmentKey = `${st.segment.weekIndex}-${st.segment.event.id}-${dayIndex}`;
|
|
302
|
+
segmentColumns.set(segmentKey, {
|
|
303
|
+
column: columnIndex,
|
|
304
|
+
totalColumns: columns.length,
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
for (const st of segmentTimes) {
|
|
309
|
+
const segmentKey = `${st.segment.weekIndex}-${st.segment.event.id}-${dayIndex}`;
|
|
310
|
+
const layout = segmentColumns.get(segmentKey);
|
|
311
|
+
if (layout) {
|
|
312
|
+
layout.totalColumns = columns.length;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Render segments
|
|
318
|
+
for (const segment of segments) {
|
|
319
|
+
const {
|
|
320
|
+
event,
|
|
321
|
+
week,
|
|
322
|
+
weekIndex,
|
|
323
|
+
startDayIndex,
|
|
324
|
+
endDayIndex,
|
|
325
|
+
isStart,
|
|
326
|
+
isEnd,
|
|
327
|
+
totalWeeks,
|
|
328
|
+
} = segment;
|
|
329
|
+
const weekYOffset = week.yOffset;
|
|
330
|
+
const allDay = event.isAllDay === true;
|
|
331
|
+
|
|
332
|
+
const startVisualPos =
|
|
333
|
+
getDayVisualPosition(startDayIndex);
|
|
334
|
+
const endVisualPos = getDayVisualPosition(endDayIndex);
|
|
335
|
+
|
|
336
|
+
let yStart: number;
|
|
337
|
+
let yEnd: number;
|
|
338
|
+
|
|
339
|
+
if (showTimeScale && !allDay) {
|
|
340
|
+
const dayStartTime = new Date(
|
|
341
|
+
week.days[startDayIndex]!,
|
|
342
|
+
).setHours(0, 0, 0, 0);
|
|
343
|
+
const dayEndTime = new Date(
|
|
344
|
+
week.days[endDayIndex]!,
|
|
345
|
+
).setHours(23, 59, 59, 999);
|
|
346
|
+
const eventStartTime = event.start.getTime();
|
|
347
|
+
const eventEndTime =
|
|
348
|
+
event.end.getTime() - 60000;
|
|
349
|
+
|
|
350
|
+
const effectiveStartTime = Math.max(
|
|
351
|
+
eventStartTime,
|
|
352
|
+
dayStartTime,
|
|
353
|
+
);
|
|
354
|
+
const effectiveEndTime = Math.min(
|
|
355
|
+
eventEndTime,
|
|
356
|
+
dayEndTime,
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
const effectiveStart = new Date(
|
|
360
|
+
effectiveStartTime,
|
|
361
|
+
);
|
|
362
|
+
const effectiveEnd = new Date(effectiveEndTime);
|
|
363
|
+
|
|
364
|
+
const startMinutes =
|
|
365
|
+
effectiveStart.getHours() * 60 +
|
|
366
|
+
effectiveStart.getMinutes();
|
|
367
|
+
const endMinutes =
|
|
368
|
+
effectiveEnd.getHours() * 60 +
|
|
369
|
+
effectiveEnd.getMinutes();
|
|
370
|
+
|
|
371
|
+
const visualRowY =
|
|
372
|
+
weekYOffset +
|
|
373
|
+
startVisualPos.row * dayHeight;
|
|
374
|
+
yStart =
|
|
375
|
+
visualRowY +
|
|
376
|
+
(startMinutes / 1440) * dayHeight;
|
|
377
|
+
yEnd =
|
|
378
|
+
visualRowY +
|
|
379
|
+
(endMinutes / 1440) * dayHeight;
|
|
380
|
+
} else {
|
|
381
|
+
const eventKey = `${weekIndex}-${event.id}`;
|
|
382
|
+
let rowIndex = eventRowIndex.get(eventKey);
|
|
383
|
+
|
|
384
|
+
if (rowIndex === undefined) {
|
|
385
|
+
rowIndex = 0;
|
|
386
|
+
while (true) {
|
|
387
|
+
let rowFree = true;
|
|
388
|
+
for (
|
|
389
|
+
let d = startDayIndex;
|
|
390
|
+
d <= endDayIndex;
|
|
391
|
+
d++
|
|
392
|
+
) {
|
|
393
|
+
const dayKey = `${weekIndex}-${d}`;
|
|
394
|
+
const occupied =
|
|
395
|
+
dayOccupiedRows.get(dayKey);
|
|
396
|
+
if (occupied?.has(rowIndex)) {
|
|
397
|
+
rowFree = false;
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (rowFree) break;
|
|
402
|
+
rowIndex++;
|
|
403
|
+
}
|
|
404
|
+
eventRowIndex.set(eventKey, rowIndex);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
for (
|
|
408
|
+
let d = startDayIndex;
|
|
409
|
+
d <= endDayIndex;
|
|
410
|
+
d++
|
|
411
|
+
) {
|
|
412
|
+
const dayKey = `${weekIndex}-${d}`;
|
|
413
|
+
let occupied = dayOccupiedRows.get(dayKey);
|
|
414
|
+
if (!occupied) {
|
|
415
|
+
occupied = new Set();
|
|
416
|
+
dayOccupiedRows.set(dayKey, occupied);
|
|
417
|
+
}
|
|
418
|
+
occupied.add(rowIndex);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const maxEventsInRow = Math.floor(
|
|
422
|
+
(dayHeight - 4) / (MIN_EVENT_HEIGHT + 2),
|
|
423
|
+
);
|
|
424
|
+
if (rowIndex >= maxEventsInRow) continue;
|
|
425
|
+
|
|
426
|
+
const visualRowY =
|
|
427
|
+
weekYOffset +
|
|
428
|
+
startVisualPos.row * dayHeight;
|
|
429
|
+
yStart =
|
|
430
|
+
visualRowY +
|
|
431
|
+
4 +
|
|
432
|
+
rowIndex * (MIN_EVENT_HEIGHT + 2);
|
|
433
|
+
yEnd = yStart + MIN_EVENT_HEIGHT;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const eventHeight = Math.max(
|
|
437
|
+
showTimeScale ? 4 : MIN_EVENT_HEIGHT,
|
|
438
|
+
yEnd - yStart,
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
let x: number;
|
|
442
|
+
let spanWidth: number;
|
|
443
|
+
|
|
444
|
+
const segmentKey = `${weekIndex}-${event.id}-${startDayIndex}`;
|
|
445
|
+
const columnLayout = segmentColumns.get(segmentKey);
|
|
446
|
+
|
|
447
|
+
if (
|
|
448
|
+
showTimeScale &&
|
|
449
|
+
!allDay &&
|
|
450
|
+
columnLayout &&
|
|
451
|
+
columnLayout.totalColumns > 1
|
|
452
|
+
) {
|
|
453
|
+
const columnWidth =
|
|
454
|
+
dayWidth / columnLayout.totalColumns;
|
|
455
|
+
x =
|
|
456
|
+
leftGutterWidth +
|
|
457
|
+
startVisualPos.col * dayWidth +
|
|
458
|
+
columnLayout.column * columnWidth;
|
|
459
|
+
spanWidth = columnWidth;
|
|
460
|
+
} else {
|
|
461
|
+
const colSpan =
|
|
462
|
+
endVisualPos.col - startVisualPos.col + 1;
|
|
463
|
+
x =
|
|
464
|
+
leftGutterWidth +
|
|
465
|
+
startVisualPos.col * dayWidth;
|
|
466
|
+
spanWidth = colSpan * dayWidth;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const viewportY = yStart - scrollTop;
|
|
470
|
+
|
|
471
|
+
if (
|
|
472
|
+
viewportY + eventHeight < 0 ||
|
|
473
|
+
viewportY > height
|
|
474
|
+
)
|
|
475
|
+
continue;
|
|
476
|
+
|
|
477
|
+
const eventColor = event.color || "#888888";
|
|
478
|
+
const isSelected = state.isEventSelected(event);
|
|
479
|
+
const isHovered =
|
|
480
|
+
state.hoveredEventId === event.id;
|
|
481
|
+
|
|
482
|
+
const padding = 2;
|
|
483
|
+
const eventWidth = spanWidth - padding * 2;
|
|
484
|
+
const leftBorderWidth = 3;
|
|
485
|
+
const contentPadding = 6;
|
|
486
|
+
|
|
487
|
+
let radiusTopLeft = 4;
|
|
488
|
+
let radiusTopRight = 4;
|
|
489
|
+
let radiusBottomLeft = 4;
|
|
490
|
+
let radiusBottomRight = 4;
|
|
491
|
+
|
|
492
|
+
if (totalWeeks > 1) {
|
|
493
|
+
if (isStart && !isEnd) {
|
|
494
|
+
radiusTopRight = 0;
|
|
495
|
+
radiusBottomRight = 0;
|
|
496
|
+
} else if (!isStart && isEnd) {
|
|
497
|
+
radiusTopLeft = 0;
|
|
498
|
+
radiusBottomLeft = 0;
|
|
499
|
+
} else if (!isStart && !isEnd) {
|
|
500
|
+
radiusTopLeft = 0;
|
|
501
|
+
radiusTopRight = 0;
|
|
502
|
+
radiusBottomLeft = 0;
|
|
503
|
+
radiusBottomRight = 0;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
ctx.save();
|
|
508
|
+
|
|
509
|
+
if (event.readOnly) {
|
|
510
|
+
ctx.globalAlpha = 0.5;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const hsl = rgbToHsl(hexToRgb(eventColor));
|
|
514
|
+
const textPrimary =
|
|
515
|
+
styles["--text-primary"] ||
|
|
516
|
+
"rgba(255, 255, 255, 0.9)";
|
|
517
|
+
const textInverse =
|
|
518
|
+
styles["--text-inverse"] || "rgb(0, 0, 0)";
|
|
519
|
+
const backgroundColor = isSelected
|
|
520
|
+
? eventColor
|
|
521
|
+
: `hsla(${hsl[0]}, ${Math.min(hsl[1] * 0.9, 90)}%, ${Math.min(hsl[2] + 15, 40)}%, 0.45)`;
|
|
522
|
+
const borderColor = isSelected
|
|
523
|
+
? eventColor
|
|
524
|
+
: `hsla(${hsl[0]}, ${Math.min(hsl[1] * 0.9, 90)}%, ${Math.min(hsl[2] + 10, 70)}%, 1)`;
|
|
525
|
+
|
|
526
|
+
const useStripes =
|
|
527
|
+
state.shouldRenderEventWithStripes(event);
|
|
528
|
+
|
|
529
|
+
ctx.beginPath();
|
|
530
|
+
ctx.roundRect(
|
|
531
|
+
x + padding,
|
|
532
|
+
viewportY,
|
|
533
|
+
eventWidth,
|
|
534
|
+
eventHeight,
|
|
535
|
+
[
|
|
536
|
+
radiusTopLeft,
|
|
537
|
+
radiusTopRight,
|
|
538
|
+
radiusBottomRight,
|
|
539
|
+
radiusBottomLeft,
|
|
540
|
+
],
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
ctx.fillStyle = backgroundColor;
|
|
544
|
+
ctx.fill();
|
|
545
|
+
|
|
546
|
+
if (useStripes) {
|
|
547
|
+
const patternCanvas =
|
|
548
|
+
state.getStripePatternCanvas();
|
|
549
|
+
if (patternCanvas) {
|
|
550
|
+
ctx.save();
|
|
551
|
+
ctx.beginPath();
|
|
552
|
+
ctx.roundRect(
|
|
553
|
+
x + padding,
|
|
554
|
+
viewportY,
|
|
555
|
+
eventWidth,
|
|
556
|
+
eventHeight,
|
|
557
|
+
[
|
|
558
|
+
radiusTopLeft,
|
|
559
|
+
radiusTopRight,
|
|
560
|
+
radiusBottomRight,
|
|
561
|
+
radiusBottomLeft,
|
|
562
|
+
],
|
|
563
|
+
);
|
|
564
|
+
ctx.clip();
|
|
565
|
+
|
|
566
|
+
const patternSize = 12;
|
|
567
|
+
const startX =
|
|
568
|
+
Math.floor((x + padding) / patternSize) *
|
|
569
|
+
patternSize;
|
|
570
|
+
const startY =
|
|
571
|
+
Math.floor(yStart / patternSize) *
|
|
572
|
+
patternSize -
|
|
573
|
+
scrollTop;
|
|
574
|
+
|
|
575
|
+
for (
|
|
576
|
+
let py = startY;
|
|
577
|
+
py < viewportY + eventHeight;
|
|
578
|
+
py += patternSize
|
|
579
|
+
) {
|
|
580
|
+
for (
|
|
581
|
+
let px = startX;
|
|
582
|
+
px < x + padding + eventWidth;
|
|
583
|
+
px += patternSize
|
|
584
|
+
) {
|
|
585
|
+
ctx.drawImage(
|
|
586
|
+
patternCanvas,
|
|
587
|
+
px,
|
|
588
|
+
py,
|
|
589
|
+
patternSize,
|
|
590
|
+
patternSize,
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
ctx.restore();
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (!isSelected) {
|
|
600
|
+
ctx.fillStyle = borderColor;
|
|
601
|
+
ctx.beginPath();
|
|
602
|
+
ctx.roundRect(
|
|
603
|
+
x + padding + 2,
|
|
604
|
+
viewportY + 2,
|
|
605
|
+
leftBorderWidth,
|
|
606
|
+
eventHeight - 4,
|
|
607
|
+
[
|
|
608
|
+
radiusTopLeft,
|
|
609
|
+
radiusTopLeft,
|
|
610
|
+
radiusBottomLeft,
|
|
611
|
+
radiusBottomLeft,
|
|
612
|
+
],
|
|
613
|
+
);
|
|
614
|
+
ctx.fill();
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (isSelected) {
|
|
618
|
+
ctx.strokeStyle = textInverse;
|
|
619
|
+
ctx.lineWidth = 1;
|
|
620
|
+
ctx.beginPath();
|
|
621
|
+
ctx.roundRect(
|
|
622
|
+
x + padding,
|
|
623
|
+
viewportY,
|
|
624
|
+
eventWidth,
|
|
625
|
+
eventHeight,
|
|
626
|
+
[
|
|
627
|
+
radiusTopLeft,
|
|
628
|
+
radiusTopRight,
|
|
629
|
+
radiusBottomRight,
|
|
630
|
+
radiusBottomLeft,
|
|
631
|
+
],
|
|
632
|
+
);
|
|
633
|
+
ctx.stroke();
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (isHovered && !isSelected) {
|
|
637
|
+
ctx.strokeStyle =
|
|
638
|
+
styles["--grid-color-hover"] ||
|
|
639
|
+
"rgba(255, 255, 255, 0.2)";
|
|
640
|
+
ctx.lineWidth = 1;
|
|
641
|
+
ctx.beginPath();
|
|
642
|
+
ctx.roundRect(
|
|
643
|
+
x + padding,
|
|
644
|
+
viewportY,
|
|
645
|
+
eventWidth,
|
|
646
|
+
eventHeight,
|
|
647
|
+
[
|
|
648
|
+
radiusTopLeft,
|
|
649
|
+
radiusTopRight,
|
|
650
|
+
radiusBottomRight,
|
|
651
|
+
radiusBottomLeft,
|
|
652
|
+
],
|
|
653
|
+
);
|
|
654
|
+
ctx.stroke();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (isStart && eventHeight >= 16) {
|
|
658
|
+
const textColor = isSelected
|
|
659
|
+
? textInverse || "white"
|
|
660
|
+
: textPrimary || eventColor;
|
|
661
|
+
ctx.fillStyle = textColor;
|
|
662
|
+
ctx.font = `11px ${fontFamily}`;
|
|
663
|
+
ctx.textAlign = "left";
|
|
664
|
+
ctx.textBaseline = "top";
|
|
665
|
+
|
|
666
|
+
const textX =
|
|
667
|
+
x +
|
|
668
|
+
padding +
|
|
669
|
+
leftBorderWidth +
|
|
670
|
+
contentPadding +
|
|
671
|
+
1;
|
|
672
|
+
const textY = viewportY + 6;
|
|
673
|
+
let maxTextWidth =
|
|
674
|
+
eventWidth -
|
|
675
|
+
leftBorderWidth -
|
|
676
|
+
contentPadding -
|
|
677
|
+
4;
|
|
678
|
+
|
|
679
|
+
ctx.save();
|
|
680
|
+
ctx.beginPath();
|
|
681
|
+
ctx.rect(
|
|
682
|
+
x + padding + leftBorderWidth,
|
|
683
|
+
viewportY,
|
|
684
|
+
eventWidth - leftBorderWidth,
|
|
685
|
+
eventHeight,
|
|
686
|
+
);
|
|
687
|
+
ctx.clip();
|
|
688
|
+
|
|
689
|
+
if (event.rrule) {
|
|
690
|
+
ctx.font = `11px ${fontFamily}`;
|
|
691
|
+
const recurIcon = "⟳";
|
|
692
|
+
const iconWidth =
|
|
693
|
+
ctx.measureText(recurIcon).width;
|
|
694
|
+
ctx.fillText(recurIcon, textX, textY);
|
|
695
|
+
maxTextWidth -= iconWidth + 4;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const titleStartX =
|
|
699
|
+
textX +
|
|
700
|
+
(event.rrule
|
|
701
|
+
? ctx.measureText("⟳").width + 4
|
|
702
|
+
: 0);
|
|
703
|
+
let displayTitle = event.title;
|
|
704
|
+
ctx.font = `11px ${fontFamily}`;
|
|
705
|
+
const titleWidth =
|
|
706
|
+
ctx.measureText(displayTitle).width;
|
|
707
|
+
|
|
708
|
+
if (titleWidth > maxTextWidth) {
|
|
709
|
+
const ellipsis = "…";
|
|
710
|
+
const ellipsisWidth =
|
|
711
|
+
ctx.measureText(ellipsis).width;
|
|
712
|
+
|
|
713
|
+
let left = 0;
|
|
714
|
+
let right = displayTitle.length;
|
|
715
|
+
let bestFit = 0;
|
|
716
|
+
|
|
717
|
+
while (left <= right) {
|
|
718
|
+
const mid = Math.floor(
|
|
719
|
+
(left + right) / 2,
|
|
720
|
+
);
|
|
721
|
+
const testText = displayTitle.substring(
|
|
722
|
+
0,
|
|
723
|
+
mid,
|
|
724
|
+
);
|
|
725
|
+
const testWidth =
|
|
726
|
+
ctx.measureText(testText).width +
|
|
727
|
+
ellipsisWidth;
|
|
728
|
+
|
|
729
|
+
if (testWidth <= maxTextWidth) {
|
|
730
|
+
bestFit = mid;
|
|
731
|
+
left = mid + 1;
|
|
732
|
+
} else {
|
|
733
|
+
right = mid - 1;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
displayTitle =
|
|
738
|
+
displayTitle.substring(0, bestFit) +
|
|
739
|
+
ellipsis;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
ctx.fillText(
|
|
743
|
+
displayTitle,
|
|
744
|
+
titleStartX,
|
|
745
|
+
textY,
|
|
746
|
+
);
|
|
747
|
+
|
|
748
|
+
if (eventHeight >= 32) {
|
|
749
|
+
const formatTime = (date: Date) => {
|
|
750
|
+
const hours = date.getHours();
|
|
751
|
+
const minutes = date.getMinutes();
|
|
752
|
+
const ampm =
|
|
753
|
+
hours >= 12 ? "PM" : "AM";
|
|
754
|
+
const displayHours =
|
|
755
|
+
hours % 12 || 12;
|
|
756
|
+
return `${displayHours}:${minutes.toString().padStart(2, "0")} ${ampm}`;
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
const startTime = formatTime(
|
|
760
|
+
event.start,
|
|
761
|
+
);
|
|
762
|
+
const endTime = formatTime(event.end);
|
|
763
|
+
const timeText = `${startTime} – ${endTime}`;
|
|
764
|
+
|
|
765
|
+
const timeTextColor = isSelected
|
|
766
|
+
? textInverse || "white"
|
|
767
|
+
: textPrimary || eventColor;
|
|
768
|
+
ctx.fillStyle = timeTextColor;
|
|
769
|
+
ctx.font = `10px ${fontFamily}`;
|
|
770
|
+
|
|
771
|
+
let displayTime = timeText;
|
|
772
|
+
let timeWidth =
|
|
773
|
+
ctx.measureText(displayTime).width;
|
|
774
|
+
|
|
775
|
+
if (timeWidth > maxTextWidth) {
|
|
776
|
+
const ellipsis = "…";
|
|
777
|
+
const ellipsisWidth =
|
|
778
|
+
ctx.measureText(ellipsis).width;
|
|
779
|
+
|
|
780
|
+
let left = 0;
|
|
781
|
+
let right = displayTime.length;
|
|
782
|
+
let bestFit = 0;
|
|
783
|
+
|
|
784
|
+
while (left <= right) {
|
|
785
|
+
const mid = Math.floor(
|
|
786
|
+
(left + right) / 2,
|
|
787
|
+
);
|
|
788
|
+
const testText =
|
|
789
|
+
displayTime.substring(0, mid);
|
|
790
|
+
const testWidth =
|
|
791
|
+
ctx.measureText(testText)
|
|
792
|
+
.width + ellipsisWidth;
|
|
793
|
+
|
|
794
|
+
if (testWidth <= maxTextWidth) {
|
|
795
|
+
bestFit = mid;
|
|
796
|
+
left = mid + 1;
|
|
797
|
+
} else {
|
|
798
|
+
right = mid - 1;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
displayTime =
|
|
803
|
+
displayTime.substring(0, bestFit) +
|
|
804
|
+
ellipsis;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
ctx.fillText(
|
|
808
|
+
displayTime,
|
|
809
|
+
textX,
|
|
810
|
+
textY + 14,
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
ctx.restore();
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
ctx.restore();
|
|
818
|
+
|
|
819
|
+
layer.eventRects.push({
|
|
820
|
+
event,
|
|
821
|
+
x: x + padding,
|
|
822
|
+
y: yStart,
|
|
823
|
+
width: eventWidth,
|
|
824
|
+
height: eventHeight,
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Render month labels
|
|
829
|
+
const monthNames = [
|
|
830
|
+
"January",
|
|
831
|
+
"February",
|
|
832
|
+
"March",
|
|
833
|
+
"April",
|
|
834
|
+
"May",
|
|
835
|
+
"June",
|
|
836
|
+
"July",
|
|
837
|
+
"August",
|
|
838
|
+
"September",
|
|
839
|
+
"October",
|
|
840
|
+
"November",
|
|
841
|
+
"December",
|
|
842
|
+
];
|
|
843
|
+
|
|
844
|
+
const monthBoundaries: {
|
|
845
|
+
monthKey: string;
|
|
846
|
+
monthName: string;
|
|
847
|
+
year: number;
|
|
848
|
+
yOffset: number;
|
|
849
|
+
}[] = [];
|
|
850
|
+
const seenMonths = new Set<string>();
|
|
851
|
+
|
|
852
|
+
for (const week of visibleWeeks) {
|
|
853
|
+
const firstDay = week.days[0];
|
|
854
|
+
if (!firstDay) continue;
|
|
855
|
+
|
|
856
|
+
const monthIndex = firstDay.getMonth();
|
|
857
|
+
const year = firstDay.getFullYear();
|
|
858
|
+
const monthKey = `${monthIndex}-${year}`;
|
|
859
|
+
|
|
860
|
+
if (!seenMonths.has(monthKey)) {
|
|
861
|
+
seenMonths.add(monthKey);
|
|
862
|
+
const monthName = monthNames[monthIndex];
|
|
863
|
+
if (monthName) {
|
|
864
|
+
monthBoundaries.push({
|
|
865
|
+
monthKey,
|
|
866
|
+
monthName,
|
|
867
|
+
year,
|
|
868
|
+
yOffset: week.yOffset,
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
for (let i = 0; i < monthBoundaries.length; i++) {
|
|
875
|
+
const month = monthBoundaries[i]!;
|
|
876
|
+
const nextMonth = monthBoundaries[i + 1];
|
|
877
|
+
const labelY = month.yOffset;
|
|
878
|
+
const nextMonthY = nextMonth
|
|
879
|
+
? nextMonth.yOffset
|
|
880
|
+
: Infinity;
|
|
881
|
+
|
|
882
|
+
if (nextMonthY < scrollTop) continue;
|
|
883
|
+
if (labelY > viewportBottom) break;
|
|
884
|
+
|
|
885
|
+
const labelPadding = [12, 0, 0, 12];
|
|
886
|
+
|
|
887
|
+
const stickyTop = Math.max(
|
|
888
|
+
0,
|
|
889
|
+
scrollTop - labelY,
|
|
890
|
+
);
|
|
891
|
+
const maxStickyTop = nextMonthY - labelY - 24;
|
|
892
|
+
const clampedStickyTop = Math.min(
|
|
893
|
+
stickyTop,
|
|
894
|
+
maxStickyTop,
|
|
895
|
+
);
|
|
896
|
+
const labelTopMargin = 32;
|
|
897
|
+
const finalTop =
|
|
898
|
+
labelY +
|
|
899
|
+
clampedStickyTop -
|
|
900
|
+
scrollTop +
|
|
901
|
+
labelTopMargin;
|
|
902
|
+
|
|
903
|
+
ctx.save();
|
|
904
|
+
ctx.font = `bold 18px ${fontFamily}`;
|
|
905
|
+
ctx.textAlign = "left";
|
|
906
|
+
ctx.textBaseline = "top";
|
|
907
|
+
|
|
908
|
+
const labelText = `${month.monthName} ${month.year}`;
|
|
909
|
+
const textWidth =
|
|
910
|
+
ctx.measureText(labelText).width;
|
|
911
|
+
const leftMargin = 8;
|
|
912
|
+
const textX =
|
|
913
|
+
64 + labelPadding[3] + leftMargin;
|
|
914
|
+
const textY = finalTop + labelPadding[0];
|
|
915
|
+
|
|
916
|
+
const bgPaddingLeft = 8;
|
|
917
|
+
const bgPaddingRight = 8;
|
|
918
|
+
const bgElevated =
|
|
919
|
+
styles["--bg-elevated"] ||
|
|
920
|
+
"rgba(0, 0, 0, 0.7)";
|
|
921
|
+
ctx.fillStyle = bgElevated;
|
|
922
|
+
ctx.beginPath();
|
|
923
|
+
ctx.roundRect(
|
|
924
|
+
textX - bgPaddingLeft,
|
|
925
|
+
textY - 4,
|
|
926
|
+
textWidth + bgPaddingLeft + bgPaddingRight,
|
|
927
|
+
26,
|
|
928
|
+
6,
|
|
929
|
+
);
|
|
930
|
+
ctx.fill();
|
|
931
|
+
|
|
932
|
+
const monthTextPrimary =
|
|
933
|
+
styles["--text-primary"] ||
|
|
934
|
+
"rgba(255, 255, 255, 0.95)";
|
|
935
|
+
ctx.fillStyle = monthTextPrimary;
|
|
936
|
+
ctx.fillText(labelText, textX, textY);
|
|
937
|
+
ctx.restore();
|
|
938
|
+
}
|
|
939
|
+
},
|
|
940
|
+
};
|
|
941
|
+
return layer;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function getDayIndexInWeek(week: WeekInfo, date: Date) {
|
|
945
|
+
const dateStart = new Date(date).setHours(0, 0, 0, 0);
|
|
946
|
+
for (let i = 0; i < 7; i++) {
|
|
947
|
+
const weekDay = week.days[i];
|
|
948
|
+
if (
|
|
949
|
+
weekDay &&
|
|
950
|
+
new Date(weekDay).setHours(0, 0, 0, 0) === dateStart
|
|
951
|
+
) {
|
|
952
|
+
return i;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
const weekStart = new Date(week.days[0]!).setHours(0, 0, 0, 0);
|
|
956
|
+
if (dateStart < weekStart) return 0;
|
|
957
|
+
return 6;
|
|
958
|
+
}
|