@mintplayer/ng-bootstrap 21.22.0 → 21.23.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/fesm2022/mintplayer-ng-bootstrap-accordion.mjs +20 -20
- package/fesm2022/mintplayer-ng-bootstrap-accordion.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-alert.mjs +8 -8
- package/fesm2022/mintplayer-ng-bootstrap-alert.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-badge.mjs +5 -5
- package/fesm2022/mintplayer-ng-bootstrap-badge.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-breadcrumb.mjs +6 -6
- package/fesm2022/mintplayer-ng-bootstrap-breadcrumb.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-button-group.mjs +3 -3
- package/fesm2022/mintplayer-ng-bootstrap-button-group.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-button-type.mjs +4 -4
- package/fesm2022/mintplayer-ng-bootstrap-button-type.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-calendar-month.mjs +9 -9
- package/fesm2022/mintplayer-ng-bootstrap-calendar-month.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-calendar.mjs +10 -10
- package/fesm2022/mintplayer-ng-bootstrap-calendar.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-card.mjs +8 -8
- package/fesm2022/mintplayer-ng-bootstrap-card.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-carousel.mjs +25 -25
- package/fesm2022/mintplayer-ng-bootstrap-carousel.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-close.mjs +3 -3
- package/fesm2022/mintplayer-ng-bootstrap-close.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-code-snippet.mjs +7 -7
- package/fesm2022/mintplayer-ng-bootstrap-code-snippet.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-color-picker.mjs +58 -58
- package/fesm2022/mintplayer-ng-bootstrap-color-picker.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-container.mjs +3 -3
- package/fesm2022/mintplayer-ng-bootstrap-container.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-context-menu.mjs +3 -3
- package/fesm2022/mintplayer-ng-bootstrap-context-menu.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-copy.mjs +4 -4
- package/fesm2022/mintplayer-ng-bootstrap-copy.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-datatable.mjs +20 -20
- package/fesm2022/mintplayer-ng-bootstrap-datatable.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-datepicker.mjs +6 -6
- package/fesm2022/mintplayer-ng-bootstrap-datepicker.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-dock.mjs +789 -1175
- package/fesm2022/mintplayer-ng-bootstrap-dock.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-dropdown-divider.mjs +3 -3
- package/fesm2022/mintplayer-ng-bootstrap-dropdown-divider.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-dropdown-menu.mjs +10 -10
- package/fesm2022/mintplayer-ng-bootstrap-dropdown-menu.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-dropdown.mjs +15 -15
- package/fesm2022/mintplayer-ng-bootstrap-dropdown.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-enhanced-paste.mjs +3 -3
- package/fesm2022/mintplayer-ng-bootstrap-enhanced-paste.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-enum.mjs +3 -3
- package/fesm2022/mintplayer-ng-bootstrap-enum.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-file-upload.mjs +16 -16
- package/fesm2022/mintplayer-ng-bootstrap-file-upload.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-floating-labels.mjs +3 -3
- package/fesm2022/mintplayer-ng-bootstrap-floating-labels.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-font-color.mjs +3 -3
- package/fesm2022/mintplayer-ng-bootstrap-font-color.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-for.mjs +4 -4
- package/fesm2022/mintplayer-ng-bootstrap-for.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-form.mjs +11 -11
- package/fesm2022/mintplayer-ng-bootstrap-form.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-grid.mjs +26 -26
- package/fesm2022/mintplayer-ng-bootstrap-grid.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-has-overlay.mjs +4 -4
- package/fesm2022/mintplayer-ng-bootstrap-has-overlay.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-has-property.mjs +3 -3
- package/fesm2022/mintplayer-ng-bootstrap-has-property.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-in-list.mjs +3 -3
- package/fesm2022/mintplayer-ng-bootstrap-in-list.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-input-group.mjs +3 -3
- package/fesm2022/mintplayer-ng-bootstrap-input-group.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-instance-of.mjs +14 -14
- package/fesm2022/mintplayer-ng-bootstrap-instance-of.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-let.mjs +4 -4
- package/fesm2022/mintplayer-ng-bootstrap-let.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-linify.mjs +3 -3
- package/fesm2022/mintplayer-ng-bootstrap-linify.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-list-group.mjs +7 -7
- package/fesm2022/mintplayer-ng-bootstrap-list-group.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-markdown.mjs +12 -12
- package/fesm2022/mintplayer-ng-bootstrap-markdown.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-marquee.mjs +3 -3
- package/fesm2022/mintplayer-ng-bootstrap-marquee.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-modal.mjs +24 -24
- package/fesm2022/mintplayer-ng-bootstrap-modal.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-multiselect.mjs +24 -24
- package/fesm2022/mintplayer-ng-bootstrap-multiselect.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-navbar-toggler.mjs +5 -5
- package/fesm2022/mintplayer-ng-bootstrap-navbar-toggler.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-navbar.mjs +58 -58
- package/fesm2022/mintplayer-ng-bootstrap-navbar.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-navigation-lock.mjs +8 -8
- package/fesm2022/mintplayer-ng-bootstrap-navigation-lock.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-no-noscript.mjs +3 -3
- package/fesm2022/mintplayer-ng-bootstrap-no-noscript.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-offcanvas.mjs +40 -40
- package/fesm2022/mintplayer-ng-bootstrap-offcanvas.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-ordinal-number.mjs +3 -3
- package/fesm2022/mintplayer-ng-bootstrap-ordinal-number.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-pagination.mjs +12 -12
- package/fesm2022/mintplayer-ng-bootstrap-pagination.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-parallax.mjs +6 -6
- package/fesm2022/mintplayer-ng-bootstrap-parallax.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-placeholder.mjs +7 -7
- package/fesm2022/mintplayer-ng-bootstrap-placeholder.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-playlist-toggler.mjs +5 -5
- package/fesm2022/mintplayer-ng-bootstrap-playlist-toggler.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-popover.mjs +20 -20
- package/fesm2022/mintplayer-ng-bootstrap-popover.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-priority-nav.mjs +30 -30
- package/fesm2022/mintplayer-ng-bootstrap-priority-nav.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-progress-bar.mjs +17 -17
- package/fesm2022/mintplayer-ng-bootstrap-progress-bar.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-range.mjs +9 -9
- package/fesm2022/mintplayer-ng-bootstrap-range.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-rating.mjs +7 -7
- package/fesm2022/mintplayer-ng-bootstrap-rating.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-resizable.mjs +25 -25
- package/fesm2022/mintplayer-ng-bootstrap-resizable.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-scheduler.mjs +16 -16
- package/fesm2022/mintplayer-ng-bootstrap-scheduler.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-scrollspy.mjs +14 -14
- package/fesm2022/mintplayer-ng-bootstrap-scrollspy.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-searchbox.mjs +24 -24
- package/fesm2022/mintplayer-ng-bootstrap-searchbox.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-select.mjs +19 -19
- package/fesm2022/mintplayer-ng-bootstrap-select.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-select2.mjs +20 -20
- package/fesm2022/mintplayer-ng-bootstrap-select2.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-shell.mjs +11 -11
- package/fesm2022/mintplayer-ng-bootstrap-shell.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-signature-pad.mjs +7 -7
- package/fesm2022/mintplayer-ng-bootstrap-signature-pad.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-slugify.mjs +3 -3
- package/fesm2022/mintplayer-ng-bootstrap-slugify.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-spinner.mjs +7 -7
- package/fesm2022/mintplayer-ng-bootstrap-spinner.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-split-string.mjs +3 -3
- package/fesm2022/mintplayer-ng-bootstrap-split-string.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-sticky-footer.mjs +6 -6
- package/fesm2022/mintplayer-ng-bootstrap-sticky-footer.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-tab-control.mjs +57 -67
- package/fesm2022/mintplayer-ng-bootstrap-tab-control.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-table.mjs +10 -10
- package/fesm2022/mintplayer-ng-bootstrap-table.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-timepicker.mjs +8 -8
- package/fesm2022/mintplayer-ng-bootstrap-timepicker.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-toast.mjs +24 -24
- package/fesm2022/mintplayer-ng-bootstrap-toast.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-toggle-button.mjs +22 -22
- package/fesm2022/mintplayer-ng-bootstrap-toggle-button.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-tooltip.mjs +10 -10
- package/fesm2022/mintplayer-ng-bootstrap-tooltip.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-treeview.mjs +14 -14
- package/fesm2022/mintplayer-ng-bootstrap-treeview.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-trust-html.mjs +3 -3
- package/fesm2022/mintplayer-ng-bootstrap-trust-html.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-typeahead.mjs +10 -10
- package/fesm2022/mintplayer-ng-bootstrap-typeahead.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-uc-first.mjs +3 -3
- package/fesm2022/mintplayer-ng-bootstrap-uc-first.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-user-agent.mjs +3 -3
- package/fesm2022/mintplayer-ng-bootstrap-user-agent.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-viewport.mjs +3 -3
- package/fesm2022/mintplayer-ng-bootstrap-viewport.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-virtual-datatable.mjs +10 -10
- package/fesm2022/mintplayer-ng-bootstrap-virtual-datatable.mjs.map +1 -1
- package/fesm2022/mintplayer-ng-bootstrap-web-components-scheduler-core.mjs +1356 -0
- package/fesm2022/mintplayer-ng-bootstrap-web-components-scheduler-core.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-bootstrap-web-components-scheduler.mjs +3819 -0
- package/fesm2022/mintplayer-ng-bootstrap-web-components-scheduler.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-bootstrap-web-components-splitter.mjs +731 -0
- package/fesm2022/mintplayer-ng-bootstrap-web-components-splitter.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-bootstrap-web-components-tab-control.mjs +549 -0
- package/fesm2022/mintplayer-ng-bootstrap-web-components-tab-control.mjs.map +1 -0
- package/fesm2022/mintplayer-ng-bootstrap-word-count.mjs +3 -3
- package/fesm2022/mintplayer-ng-bootstrap-word-count.mjs.map +1 -1
- package/package.json +20 -6
- package/types/mintplayer-ng-bootstrap-dock.d.ts +55 -19
- package/types/mintplayer-ng-bootstrap-scheduler.d.ts +2 -2
- package/types/mintplayer-ng-bootstrap-tab-control.d.ts +7 -11
- package/types/mintplayer-ng-bootstrap-web-components-scheduler-core.d.ts +890 -0
- package/types/mintplayer-ng-bootstrap-web-components-scheduler.d.ts +354 -0
- package/types/mintplayer-ng-bootstrap-web-components-splitter.d.ts +165 -0
- package/types/mintplayer-ng-bootstrap-web-components-tab-control.d.ts +95 -0
|
@@ -0,0 +1,3819 @@
|
|
|
1
|
+
import { unsafeCSS, LitElement, html } from 'lit';
|
|
2
|
+
import { DEFAULT_OPTIONS, dateService, timelineService, getContrastColor, resourceService, isResourceGroup, isResource, generateEventId } from '@mintplayer/ng-bootstrap/web-components/scheduler-core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create initial scheduler state
|
|
6
|
+
*/
|
|
7
|
+
function createInitialState(options = {}) {
|
|
8
|
+
const mergedOptions = { ...DEFAULT_OPTIONS, ...options };
|
|
9
|
+
return {
|
|
10
|
+
view: mergedOptions.initialView,
|
|
11
|
+
date: mergedOptions.initialDate,
|
|
12
|
+
events: [],
|
|
13
|
+
resources: [],
|
|
14
|
+
options: mergedOptions,
|
|
15
|
+
selectedEvent: null,
|
|
16
|
+
hoveredEvent: null,
|
|
17
|
+
hoveredSlot: null,
|
|
18
|
+
dragState: null,
|
|
19
|
+
previewEvent: null,
|
|
20
|
+
collapsedGroups: new Set(),
|
|
21
|
+
isMouseDown: false,
|
|
22
|
+
isLoading: false,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* State manager for the scheduler
|
|
27
|
+
*/
|
|
28
|
+
class SchedulerStateManager {
|
|
29
|
+
constructor(initialOptions = {}) {
|
|
30
|
+
this.listeners = new Set();
|
|
31
|
+
this.state = createInitialState(initialOptions);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Get current state
|
|
35
|
+
*/
|
|
36
|
+
getState() {
|
|
37
|
+
return this.state;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Update state with partial update or updater function
|
|
41
|
+
*/
|
|
42
|
+
setState(update) {
|
|
43
|
+
const partialUpdate = typeof update === 'function' ? update(this.state) : update;
|
|
44
|
+
this.state = { ...this.state, ...partialUpdate };
|
|
45
|
+
this.notifyListeners();
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Subscribe to state changes
|
|
49
|
+
*/
|
|
50
|
+
subscribe(listener) {
|
|
51
|
+
this.listeners.add(listener);
|
|
52
|
+
return () => this.listeners.delete(listener);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Notify all listeners of state change
|
|
56
|
+
*/
|
|
57
|
+
notifyListeners() {
|
|
58
|
+
for (const listener of this.listeners) {
|
|
59
|
+
listener(this.state);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Convenience methods for common state updates
|
|
63
|
+
/**
|
|
64
|
+
* Set the current view
|
|
65
|
+
*/
|
|
66
|
+
setView(view) {
|
|
67
|
+
this.setState({ view });
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Set the current date
|
|
71
|
+
*/
|
|
72
|
+
setDate(date) {
|
|
73
|
+
this.setState({ date });
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Set events
|
|
77
|
+
*/
|
|
78
|
+
setEvents(events) {
|
|
79
|
+
this.setState({ events });
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Add an event
|
|
83
|
+
*/
|
|
84
|
+
addEvent(event) {
|
|
85
|
+
this.setState((state) => ({
|
|
86
|
+
events: [...state.events, event],
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Update an event
|
|
91
|
+
*/
|
|
92
|
+
updateEvent(event) {
|
|
93
|
+
this.setState((state) => ({
|
|
94
|
+
events: state.events.map((e) => (e.id === event.id ? event : e)),
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Remove an event
|
|
99
|
+
*/
|
|
100
|
+
removeEvent(eventId) {
|
|
101
|
+
this.setState((state) => ({
|
|
102
|
+
events: state.events.filter((e) => e.id !== eventId),
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Set resources
|
|
107
|
+
*/
|
|
108
|
+
setResources(resources) {
|
|
109
|
+
this.setState({ resources });
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Toggle resource group collapse
|
|
113
|
+
*/
|
|
114
|
+
toggleGroupCollapse(groupId) {
|
|
115
|
+
this.setState((state) => {
|
|
116
|
+
const newCollapsed = new Set(state.collapsedGroups);
|
|
117
|
+
if (newCollapsed.has(groupId)) {
|
|
118
|
+
newCollapsed.delete(groupId);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
newCollapsed.add(groupId);
|
|
122
|
+
}
|
|
123
|
+
return { collapsedGroups: newCollapsed };
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Set selected event
|
|
128
|
+
*/
|
|
129
|
+
setSelectedEvent(event) {
|
|
130
|
+
this.setState({ selectedEvent: event });
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Set hovered event
|
|
134
|
+
*/
|
|
135
|
+
setHoveredEvent(event) {
|
|
136
|
+
this.setState({ hoveredEvent: event });
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Set hovered slot
|
|
140
|
+
*/
|
|
141
|
+
setHoveredSlot(slot) {
|
|
142
|
+
this.setState({ hoveredSlot: slot });
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Start a drag operation
|
|
146
|
+
*/
|
|
147
|
+
startDrag(dragState) {
|
|
148
|
+
this.setState({
|
|
149
|
+
dragState,
|
|
150
|
+
previewEvent: dragState.preview,
|
|
151
|
+
isMouseDown: true,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Update drag operation
|
|
156
|
+
*/
|
|
157
|
+
updateDrag(currentSlot, preview) {
|
|
158
|
+
this.setState((state) => ({
|
|
159
|
+
dragState: state.dragState
|
|
160
|
+
? { ...state.dragState, currentSlot, preview }
|
|
161
|
+
: null,
|
|
162
|
+
previewEvent: preview,
|
|
163
|
+
}));
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* End drag operation
|
|
167
|
+
*/
|
|
168
|
+
endDrag() {
|
|
169
|
+
this.setState({
|
|
170
|
+
dragState: null,
|
|
171
|
+
previewEvent: null,
|
|
172
|
+
isMouseDown: false,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Set mouse down state
|
|
177
|
+
*/
|
|
178
|
+
setMouseDown(isMouseDown) {
|
|
179
|
+
this.setState({ isMouseDown });
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Set loading state
|
|
183
|
+
*/
|
|
184
|
+
setLoading(isLoading) {
|
|
185
|
+
this.setState({ isLoading });
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Update options
|
|
189
|
+
*/
|
|
190
|
+
setOptions(options) {
|
|
191
|
+
this.setState((state) => ({
|
|
192
|
+
options: { ...state.options, ...options },
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Navigate to next period
|
|
197
|
+
*/
|
|
198
|
+
next() {
|
|
199
|
+
this.setState((state) => {
|
|
200
|
+
const newDate = new Date(state.date);
|
|
201
|
+
switch (state.view) {
|
|
202
|
+
case 'year':
|
|
203
|
+
newDate.setFullYear(newDate.getFullYear() + 1);
|
|
204
|
+
break;
|
|
205
|
+
case 'month':
|
|
206
|
+
newDate.setMonth(newDate.getMonth() + 1);
|
|
207
|
+
break;
|
|
208
|
+
case 'week':
|
|
209
|
+
case 'timeline':
|
|
210
|
+
newDate.setDate(newDate.getDate() + 7);
|
|
211
|
+
break;
|
|
212
|
+
case 'day':
|
|
213
|
+
newDate.setDate(newDate.getDate() + 1);
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
return { date: newDate };
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Navigate to previous period
|
|
221
|
+
*/
|
|
222
|
+
prev() {
|
|
223
|
+
this.setState((state) => {
|
|
224
|
+
const newDate = new Date(state.date);
|
|
225
|
+
switch (state.view) {
|
|
226
|
+
case 'year':
|
|
227
|
+
newDate.setFullYear(newDate.getFullYear() - 1);
|
|
228
|
+
break;
|
|
229
|
+
case 'month':
|
|
230
|
+
newDate.setMonth(newDate.getMonth() - 1);
|
|
231
|
+
break;
|
|
232
|
+
case 'week':
|
|
233
|
+
case 'timeline':
|
|
234
|
+
newDate.setDate(newDate.getDate() - 7);
|
|
235
|
+
break;
|
|
236
|
+
case 'day':
|
|
237
|
+
newDate.setDate(newDate.getDate() - 1);
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
return { date: newDate };
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Navigate to today
|
|
245
|
+
*/
|
|
246
|
+
today() {
|
|
247
|
+
this.setState({ date: new Date() });
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Navigate to a specific date
|
|
251
|
+
*/
|
|
252
|
+
gotoDate(date) {
|
|
253
|
+
this.setState({ date });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Base class for scheduler views
|
|
259
|
+
*/
|
|
260
|
+
class BaseView {
|
|
261
|
+
constructor(container, state) {
|
|
262
|
+
this.container = container;
|
|
263
|
+
this.state = state;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Update the now indicator position (called every minute)
|
|
267
|
+
* Default implementation does nothing - override in views that have a now indicator
|
|
268
|
+
*/
|
|
269
|
+
updateNowIndicator() {
|
|
270
|
+
// Default: do nothing
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Get the view's root element
|
|
274
|
+
*/
|
|
275
|
+
getElement() {
|
|
276
|
+
return this.container;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Helper to create an element with classes
|
|
280
|
+
*/
|
|
281
|
+
createElement(tag, ...classes) {
|
|
282
|
+
const el = document.createElement(tag);
|
|
283
|
+
if (classes.length > 0) {
|
|
284
|
+
el.classList.add(...classes);
|
|
285
|
+
}
|
|
286
|
+
return el;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Helper to set data attributes
|
|
290
|
+
*/
|
|
291
|
+
setData(element, data) {
|
|
292
|
+
for (const [key, value] of Object.entries(data)) {
|
|
293
|
+
element.dataset[key] = String(value);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Helper to clear container
|
|
298
|
+
*/
|
|
299
|
+
clearContainer() {
|
|
300
|
+
this.container.innerHTML = '';
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Year view renderer
|
|
306
|
+
*/
|
|
307
|
+
class YearView extends BaseView {
|
|
308
|
+
render() {
|
|
309
|
+
this.clearContainer();
|
|
310
|
+
this.container.classList.add('scheduler-year-view');
|
|
311
|
+
const { date, options } = this.state;
|
|
312
|
+
const months = dateService.getYearMonths(date);
|
|
313
|
+
const grid = this.createElement('div', 'scheduler-year-grid');
|
|
314
|
+
for (const month of months) {
|
|
315
|
+
const monthEl = this.createMonthCard(month);
|
|
316
|
+
grid.appendChild(monthEl);
|
|
317
|
+
}
|
|
318
|
+
this.container.appendChild(grid);
|
|
319
|
+
}
|
|
320
|
+
createMonthCard(month) {
|
|
321
|
+
const { events, options } = this.state;
|
|
322
|
+
const card = this.createElement('div', 'scheduler-year-month');
|
|
323
|
+
// Month header
|
|
324
|
+
const header = this.createElement('div', 'scheduler-year-month-header');
|
|
325
|
+
header.textContent = dateService.getMonthName(month, options.locale);
|
|
326
|
+
this.setData(header, { month: month.toISOString() });
|
|
327
|
+
card.appendChild(header);
|
|
328
|
+
// Mini calendar
|
|
329
|
+
const miniMonth = this.createElement('div', 'scheduler-mini-month');
|
|
330
|
+
// Day headers
|
|
331
|
+
const weeks = dateService.getMonthWeeks(month, options.firstDayOfWeek);
|
|
332
|
+
const firstWeek = weeks[0];
|
|
333
|
+
for (const day of firstWeek) {
|
|
334
|
+
const dayHeader = this.createElement('div', 'scheduler-mini-day', 'header');
|
|
335
|
+
dayHeader.textContent = dateService.getDayName(day, options.locale, 'narrow');
|
|
336
|
+
dayHeader.style.fontWeight = '600';
|
|
337
|
+
dayHeader.style.color = '#666';
|
|
338
|
+
miniMonth.appendChild(dayHeader);
|
|
339
|
+
}
|
|
340
|
+
// Days
|
|
341
|
+
const monthStart = dateService.getMonthStart(month);
|
|
342
|
+
const monthEnd = dateService.getMonthEnd(month);
|
|
343
|
+
const monthEvents = timelineService.filterByRange(events, monthStart, monthEnd);
|
|
344
|
+
// Create a set of dates that have events
|
|
345
|
+
const datesWithEvents = new Set();
|
|
346
|
+
for (const event of monthEvents) {
|
|
347
|
+
const current = new Date(event.start);
|
|
348
|
+
current.setHours(0, 0, 0, 0);
|
|
349
|
+
while (current <= event.end) {
|
|
350
|
+
datesWithEvents.add(current.toISOString().split('T')[0]);
|
|
351
|
+
current.setDate(current.getDate() + 1);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
for (const week of weeks) {
|
|
355
|
+
for (const day of week) {
|
|
356
|
+
const dayEl = this.createElement('div', 'scheduler-mini-day');
|
|
357
|
+
dayEl.textContent = String(day.getDate());
|
|
358
|
+
this.setData(dayEl, { date: day.toISOString() });
|
|
359
|
+
if (!dateService.isSameMonth(day, month)) {
|
|
360
|
+
dayEl.classList.add('other-month');
|
|
361
|
+
}
|
|
362
|
+
if (dateService.isToday(day)) {
|
|
363
|
+
dayEl.classList.add('today');
|
|
364
|
+
}
|
|
365
|
+
const dateKey = day.toISOString().split('T')[0];
|
|
366
|
+
if (datesWithEvents.has(dateKey)) {
|
|
367
|
+
dayEl.classList.add('has-events');
|
|
368
|
+
}
|
|
369
|
+
miniMonth.appendChild(dayEl);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
card.appendChild(miniMonth);
|
|
373
|
+
return card;
|
|
374
|
+
}
|
|
375
|
+
update(state) {
|
|
376
|
+
this.state = state;
|
|
377
|
+
// Year view is mostly static, re-render fully
|
|
378
|
+
this.render();
|
|
379
|
+
}
|
|
380
|
+
destroy() {
|
|
381
|
+
this.clearContainer();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Month view renderer
|
|
387
|
+
*/
|
|
388
|
+
class MonthView extends BaseView {
|
|
389
|
+
constructor() {
|
|
390
|
+
super(...arguments);
|
|
391
|
+
this.dayCells = new Map();
|
|
392
|
+
}
|
|
393
|
+
render() {
|
|
394
|
+
this.clearContainer();
|
|
395
|
+
this.container.classList.add('scheduler-month-view');
|
|
396
|
+
const { date, options } = this.state;
|
|
397
|
+
const weeks = dateService.getMonthWeeks(date, options.firstDayOfWeek);
|
|
398
|
+
// Create day-of-week headers
|
|
399
|
+
const headers = this.createElement('div', 'scheduler-day-headers');
|
|
400
|
+
const firstWeek = weeks[0];
|
|
401
|
+
for (const day of firstWeek) {
|
|
402
|
+
const header = this.createElement('div', 'scheduler-day-header');
|
|
403
|
+
header.textContent = dateService.getDayName(day, options.locale);
|
|
404
|
+
headers.appendChild(header);
|
|
405
|
+
}
|
|
406
|
+
this.container.appendChild(headers);
|
|
407
|
+
// Create month grid
|
|
408
|
+
const grid = this.createElement('div', 'scheduler-month-grid');
|
|
409
|
+
for (const week of weeks) {
|
|
410
|
+
for (const day of week) {
|
|
411
|
+
const cell = this.createDayCell(day);
|
|
412
|
+
grid.appendChild(cell);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
this.container.appendChild(grid);
|
|
416
|
+
// Render events
|
|
417
|
+
this.renderEvents();
|
|
418
|
+
}
|
|
419
|
+
createDayCell(day) {
|
|
420
|
+
const { date } = this.state;
|
|
421
|
+
const cell = this.createElement('div', 'scheduler-month-day');
|
|
422
|
+
if (!dateService.isSameMonth(day, date)) {
|
|
423
|
+
cell.classList.add('other-month');
|
|
424
|
+
}
|
|
425
|
+
if (dateService.isToday(day)) {
|
|
426
|
+
cell.classList.add('today');
|
|
427
|
+
}
|
|
428
|
+
// Day number
|
|
429
|
+
const dayNumber = this.createElement('div', 'day-number');
|
|
430
|
+
dayNumber.textContent = String(day.getDate());
|
|
431
|
+
cell.appendChild(dayNumber);
|
|
432
|
+
// Events container
|
|
433
|
+
const eventsContainer = this.createElement('div', 'month-events');
|
|
434
|
+
cell.appendChild(eventsContainer);
|
|
435
|
+
// Store reference
|
|
436
|
+
const key = day.toISOString().split('T')[0];
|
|
437
|
+
this.dayCells.set(key, cell);
|
|
438
|
+
this.setData(cell, { date: key });
|
|
439
|
+
return cell;
|
|
440
|
+
}
|
|
441
|
+
renderEvents() {
|
|
442
|
+
const { date, events, options } = this.state;
|
|
443
|
+
const monthStart = dateService.getMonthStart(date);
|
|
444
|
+
const monthEnd = dateService.getMonthEnd(date);
|
|
445
|
+
// Get weeks for full view range
|
|
446
|
+
const weeks = dateService.getMonthWeeks(date, options.firstDayOfWeek);
|
|
447
|
+
const viewStart = weeks[0][0];
|
|
448
|
+
const viewEnd = weeks[weeks.length - 1][6];
|
|
449
|
+
viewEnd.setHours(23, 59, 59, 999);
|
|
450
|
+
// Filter events for the view range
|
|
451
|
+
const viewEvents = timelineService.filterByRange(events, viewStart, viewEnd);
|
|
452
|
+
// Group events by day
|
|
453
|
+
const eventsByDay = new Map();
|
|
454
|
+
for (const event of viewEvents) {
|
|
455
|
+
const eventStart = new Date(event.start);
|
|
456
|
+
const eventEnd = new Date(event.end);
|
|
457
|
+
// Iterate through each day the event spans
|
|
458
|
+
const current = new Date(eventStart);
|
|
459
|
+
current.setHours(0, 0, 0, 0);
|
|
460
|
+
while (current <= eventEnd) {
|
|
461
|
+
const key = current.toISOString().split('T')[0];
|
|
462
|
+
if (!eventsByDay.has(key)) {
|
|
463
|
+
eventsByDay.set(key, []);
|
|
464
|
+
}
|
|
465
|
+
eventsByDay.get(key).push(event);
|
|
466
|
+
current.setDate(current.getDate() + 1);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// Render events in each day cell
|
|
470
|
+
const maxEventsPerDay = typeof options.dayMaxEvents === 'number'
|
|
471
|
+
? options.dayMaxEvents
|
|
472
|
+
: 3;
|
|
473
|
+
for (const [key, dayEvents] of eventsByDay) {
|
|
474
|
+
const cell = this.dayCells.get(key);
|
|
475
|
+
if (!cell)
|
|
476
|
+
continue;
|
|
477
|
+
const eventsContainer = cell.querySelector('.month-events');
|
|
478
|
+
if (!eventsContainer)
|
|
479
|
+
continue;
|
|
480
|
+
// Clear existing events
|
|
481
|
+
eventsContainer.innerHTML = '';
|
|
482
|
+
const visibleEvents = dayEvents.slice(0, maxEventsPerDay);
|
|
483
|
+
const hiddenCount = dayEvents.length - visibleEvents.length;
|
|
484
|
+
for (const event of visibleEvents) {
|
|
485
|
+
const eventEl = this.createElement('div', 'scheduler-month-event');
|
|
486
|
+
eventEl.textContent = event.title;
|
|
487
|
+
eventEl.style.backgroundColor = event.color ?? '#3788d8';
|
|
488
|
+
eventEl.style.color = event.textColor ?? getContrastColor(event.color ?? '#3788d8');
|
|
489
|
+
this.setData(eventEl, { eventId: event.id });
|
|
490
|
+
eventsContainer.appendChild(eventEl);
|
|
491
|
+
}
|
|
492
|
+
if (hiddenCount > 0) {
|
|
493
|
+
const moreLink = this.createElement('div', 'scheduler-more-link');
|
|
494
|
+
moreLink.textContent = `+${hiddenCount} more`;
|
|
495
|
+
this.setData(moreLink, { date: key });
|
|
496
|
+
eventsContainer.appendChild(moreLink);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
update(state) {
|
|
501
|
+
const dateChanged = this.state.date.getMonth() !== state.date.getMonth() ||
|
|
502
|
+
this.state.date.getFullYear() !== state.date.getFullYear();
|
|
503
|
+
const optionsChanged = this.optionsRequireRerender(this.state.options, state.options);
|
|
504
|
+
this.state = state;
|
|
505
|
+
// If month or relevant options changed, we need to re-render the entire view
|
|
506
|
+
if (dateChanged || optionsChanged) {
|
|
507
|
+
this.render();
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
this.renderEvents();
|
|
511
|
+
}
|
|
512
|
+
optionsRequireRerender(oldOpts, newOpts) {
|
|
513
|
+
return oldOpts.firstDayOfWeek !== newOpts.firstDayOfWeek ||
|
|
514
|
+
oldOpts.dayMaxEvents !== newOpts.dayMaxEvents ||
|
|
515
|
+
oldOpts.locale !== newOpts.locale;
|
|
516
|
+
}
|
|
517
|
+
destroy() {
|
|
518
|
+
this.dayCells.clear();
|
|
519
|
+
this.clearContainer();
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Week view renderer
|
|
525
|
+
*/
|
|
526
|
+
class WeekView extends BaseView {
|
|
527
|
+
constructor() {
|
|
528
|
+
super(...arguments);
|
|
529
|
+
this.dayColumns = [];
|
|
530
|
+
this.eventElements = new Map();
|
|
531
|
+
this.slotElements = new Map();
|
|
532
|
+
}
|
|
533
|
+
render() {
|
|
534
|
+
this.clearContainer();
|
|
535
|
+
this.container.classList.add('scheduler-week-view');
|
|
536
|
+
const { date, options } = this.state;
|
|
537
|
+
const days = dateService.getWeekDays(date, options.firstDayOfWeek);
|
|
538
|
+
// Create day headers
|
|
539
|
+
const headers = this.createElement('div', 'scheduler-day-headers');
|
|
540
|
+
// Add time gutter space
|
|
541
|
+
const gutterSpace = this.createElement('div', 'scheduler-time-gutter-space');
|
|
542
|
+
gutterSpace.style.width = 'var(--scheduler-time-gutter-width)';
|
|
543
|
+
headers.appendChild(gutterSpace);
|
|
544
|
+
for (const day of days) {
|
|
545
|
+
const header = this.createElement('div', 'scheduler-day-header');
|
|
546
|
+
if (dateService.isToday(day)) {
|
|
547
|
+
header.classList.add('today');
|
|
548
|
+
}
|
|
549
|
+
const dayName = this.createElement('div', 'day-name');
|
|
550
|
+
dayName.textContent = dateService.getDayName(day, options.locale);
|
|
551
|
+
const dayNumber = this.createElement('div', 'day-number');
|
|
552
|
+
dayNumber.textContent = String(day.getDate());
|
|
553
|
+
header.appendChild(dayName);
|
|
554
|
+
header.appendChild(dayNumber);
|
|
555
|
+
headers.appendChild(header);
|
|
556
|
+
}
|
|
557
|
+
this.container.appendChild(headers);
|
|
558
|
+
// Create time grid
|
|
559
|
+
const timeGrid = this.createElement('div', 'scheduler-time-grid');
|
|
560
|
+
// Time gutter
|
|
561
|
+
const timeGutter = this.createElement('div', 'scheduler-time-gutter');
|
|
562
|
+
const slots = dateService.getTimeSlots(days[0], options.slotDuration, options.slotMinTime, options.slotMaxTime);
|
|
563
|
+
for (const slot of slots) {
|
|
564
|
+
const label = this.createElement('div', 'scheduler-time-slot-label');
|
|
565
|
+
label.textContent = dateService.formatTime(slot.start, options.timeFormat);
|
|
566
|
+
timeGutter.appendChild(label);
|
|
567
|
+
}
|
|
568
|
+
timeGrid.appendChild(timeGutter);
|
|
569
|
+
// Days container
|
|
570
|
+
const daysContainer = this.createElement('div', 'scheduler-days-container');
|
|
571
|
+
this.dayColumns = [];
|
|
572
|
+
for (let dayIndex = 0; dayIndex < days.length; dayIndex++) {
|
|
573
|
+
const day = days[dayIndex];
|
|
574
|
+
const dayColumn = this.createElement('div', 'scheduler-day-column');
|
|
575
|
+
this.setData(dayColumn, { dayIndex });
|
|
576
|
+
// Create time slots
|
|
577
|
+
for (let slotIndex = 0; slotIndex < slots.length; slotIndex++) {
|
|
578
|
+
const slotTemplate = slots[slotIndex];
|
|
579
|
+
const slotStart = new Date(day);
|
|
580
|
+
slotStart.setHours(slotTemplate.start.getHours(), slotTemplate.start.getMinutes(), 0, 0);
|
|
581
|
+
const slotEnd = new Date(day);
|
|
582
|
+
slotEnd.setHours(slotTemplate.end.getHours(), slotTemplate.end.getMinutes(), 0, 0);
|
|
583
|
+
const slotEl = this.createElement('div', 'scheduler-time-slot');
|
|
584
|
+
this.setData(slotEl, {
|
|
585
|
+
dayIndex,
|
|
586
|
+
slotIndex,
|
|
587
|
+
start: slotStart.toISOString(),
|
|
588
|
+
end: slotEnd.toISOString(),
|
|
589
|
+
});
|
|
590
|
+
const key = `${dayIndex}-${slotIndex}`;
|
|
591
|
+
this.slotElements.set(key, slotEl);
|
|
592
|
+
dayColumn.appendChild(slotEl);
|
|
593
|
+
}
|
|
594
|
+
// Events container for this day
|
|
595
|
+
const eventsContainer = this.createElement('div', 'scheduler-events-container');
|
|
596
|
+
dayColumn.appendChild(eventsContainer);
|
|
597
|
+
this.dayColumns.push(dayColumn);
|
|
598
|
+
daysContainer.appendChild(dayColumn);
|
|
599
|
+
}
|
|
600
|
+
timeGrid.appendChild(daysContainer);
|
|
601
|
+
this.container.appendChild(timeGrid);
|
|
602
|
+
// Render events
|
|
603
|
+
this.renderEvents();
|
|
604
|
+
// Render now indicator
|
|
605
|
+
this.renderNowIndicator(days, slots);
|
|
606
|
+
}
|
|
607
|
+
update(state) {
|
|
608
|
+
const dateChanged = this.state.date.getTime() !== state.date.getTime();
|
|
609
|
+
const optionsChanged = this.optionsRequireRerender(this.state.options, state.options);
|
|
610
|
+
const selectionChanged = this.state.selectedEvent?.id !== state.selectedEvent?.id;
|
|
611
|
+
this.state = state;
|
|
612
|
+
// If date or relevant options changed, we need to re-render the entire view
|
|
613
|
+
if (dateChanged || optionsChanged) {
|
|
614
|
+
this.render();
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
// Update greyed slots based on drag state
|
|
618
|
+
this.updateGreyedSlots();
|
|
619
|
+
// Re-render events if selection changed or needed
|
|
620
|
+
if (selectionChanged) {
|
|
621
|
+
this.renderEvents();
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
this.renderEvents();
|
|
625
|
+
}
|
|
626
|
+
// Render preview event
|
|
627
|
+
this.renderPreviewEvent();
|
|
628
|
+
}
|
|
629
|
+
optionsRequireRerender(oldOpts, newOpts) {
|
|
630
|
+
return oldOpts.slotDuration !== newOpts.slotDuration ||
|
|
631
|
+
oldOpts.timeFormat !== newOpts.timeFormat ||
|
|
632
|
+
oldOpts.firstDayOfWeek !== newOpts.firstDayOfWeek ||
|
|
633
|
+
oldOpts.slotMinTime !== newOpts.slotMinTime ||
|
|
634
|
+
oldOpts.slotMaxTime !== newOpts.slotMaxTime ||
|
|
635
|
+
oldOpts.locale !== newOpts.locale;
|
|
636
|
+
}
|
|
637
|
+
renderEvents() {
|
|
638
|
+
const { date, events, options } = this.state;
|
|
639
|
+
const days = dateService.getWeekDays(date, options.firstDayOfWeek);
|
|
640
|
+
const weekStart = days[0];
|
|
641
|
+
const weekEnd = new Date(days[6]);
|
|
642
|
+
weekEnd.setHours(23, 59, 59, 999);
|
|
643
|
+
// Filter events for this week
|
|
644
|
+
const weekEvents = timelineService.filterByRange(events, weekStart, weekEnd);
|
|
645
|
+
// Split events into parts and get timeline
|
|
646
|
+
const allParts = [];
|
|
647
|
+
for (const event of weekEvents) {
|
|
648
|
+
const { parts } = timelineService.splitInParts(event);
|
|
649
|
+
allParts.push(...parts);
|
|
650
|
+
}
|
|
651
|
+
// Filter parts for this week
|
|
652
|
+
const weekParts = timelineService.filterPartsByRange(allParts, weekStart, weekEnd);
|
|
653
|
+
// Get timelened parts with track info
|
|
654
|
+
const timelinedParts = timelineService.getTimelinedParts(weekParts);
|
|
655
|
+
// Clear existing events
|
|
656
|
+
this.eventElements.forEach((el) => el.remove());
|
|
657
|
+
this.eventElements.clear();
|
|
658
|
+
// Render each event part
|
|
659
|
+
for (const { part, trackIndex, totalTracks, colspan } of timelinedParts) {
|
|
660
|
+
if (!part.event)
|
|
661
|
+
continue;
|
|
662
|
+
const dayIndex = days.findIndex((d) => dateService.isSameDay(d, part.start));
|
|
663
|
+
if (dayIndex === -1)
|
|
664
|
+
continue;
|
|
665
|
+
const dayColumn = this.dayColumns[dayIndex];
|
|
666
|
+
const eventsContainer = dayColumn.querySelector('.scheduler-events-container');
|
|
667
|
+
if (!eventsContainer)
|
|
668
|
+
continue;
|
|
669
|
+
const eventEl = this.createEventElement(part, trackIndex, totalTracks, colspan, options.slotDuration ?? 1800);
|
|
670
|
+
eventsContainer.appendChild(eventEl);
|
|
671
|
+
this.eventElements.set(part.id, eventEl);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
createEventElement(part, trackIndex, totalTracks, colspan, slotDuration) {
|
|
675
|
+
const event = part.event;
|
|
676
|
+
const eventEl = this.createElement('div', 'scheduler-event');
|
|
677
|
+
// Mark as selected if this is the selected event
|
|
678
|
+
if (this.state.selectedEvent?.id === event.id) {
|
|
679
|
+
eventEl.classList.add('selected');
|
|
680
|
+
}
|
|
681
|
+
// Calculate position
|
|
682
|
+
const dayStart = new Date(part.start);
|
|
683
|
+
dayStart.setHours(0, 0, 0, 0);
|
|
684
|
+
const startMinutes = (part.start.getTime() - dayStart.getTime()) / (1000 * 60);
|
|
685
|
+
const endMinutes = (part.end.getTime() - dayStart.getTime()) / (1000 * 60);
|
|
686
|
+
const durationMinutes = endMinutes - startMinutes;
|
|
687
|
+
const slotMinutes = slotDuration / 60;
|
|
688
|
+
const top = (startMinutes / slotMinutes) * 40; // 40px per slot
|
|
689
|
+
const height = Math.max((durationMinutes / slotMinutes) * 40, 20);
|
|
690
|
+
// Calculate width based on tracks and colspan
|
|
691
|
+
// colspan allows events to span multiple columns when there's no blocking event
|
|
692
|
+
const leftPercent = (trackIndex / totalTracks) * 100;
|
|
693
|
+
const widthPercent = (colspan / totalTracks) * 100;
|
|
694
|
+
eventEl.style.top = `${top}px`;
|
|
695
|
+
eventEl.style.height = `${height}px`;
|
|
696
|
+
eventEl.style.left = `${leftPercent}%`;
|
|
697
|
+
eventEl.style.width = `calc(${widthPercent}% - 2px)`;
|
|
698
|
+
eventEl.style.backgroundColor = event.color ?? '#3788d8';
|
|
699
|
+
eventEl.style.color = event.textColor ?? getContrastColor(event.color ?? '#3788d8');
|
|
700
|
+
this.setData(eventEl, { eventId: event.id });
|
|
701
|
+
// Title
|
|
702
|
+
const title = this.createElement('div', 'event-title');
|
|
703
|
+
title.textContent = event.title;
|
|
704
|
+
eventEl.appendChild(title);
|
|
705
|
+
// Time
|
|
706
|
+
const timeEl = this.createElement('div', 'event-time');
|
|
707
|
+
timeEl.textContent = `${dateService.formatTime(part.start, this.state.options.timeFormat)} - ${dateService.formatTime(part.end, this.state.options.timeFormat)}`;
|
|
708
|
+
eventEl.appendChild(timeEl);
|
|
709
|
+
// Resize handles
|
|
710
|
+
if (event.resizable !== false) {
|
|
711
|
+
if (part.isStart) {
|
|
712
|
+
const topHandle = this.createElement('div', 'resize-handle', 'top');
|
|
713
|
+
this.setData(topHandle, { handle: 'start' });
|
|
714
|
+
eventEl.appendChild(topHandle);
|
|
715
|
+
}
|
|
716
|
+
if (part.isEnd) {
|
|
717
|
+
const bottomHandle = this.createElement('div', 'resize-handle', 'bottom');
|
|
718
|
+
this.setData(bottomHandle, { handle: 'end' });
|
|
719
|
+
eventEl.appendChild(bottomHandle);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return eventEl;
|
|
723
|
+
}
|
|
724
|
+
renderPreviewEvent() {
|
|
725
|
+
// Remove existing preview
|
|
726
|
+
const existingPreview = this.container.querySelector('.scheduler-event.preview');
|
|
727
|
+
if (existingPreview) {
|
|
728
|
+
existingPreview.remove();
|
|
729
|
+
}
|
|
730
|
+
const { previewEvent, options, date } = this.state;
|
|
731
|
+
if (!previewEvent)
|
|
732
|
+
return;
|
|
733
|
+
const days = dateService.getWeekDays(date, options.firstDayOfWeek);
|
|
734
|
+
const dayIndex = days.findIndex((d) => dateService.isSameDay(d, previewEvent.start));
|
|
735
|
+
if (dayIndex === -1)
|
|
736
|
+
return;
|
|
737
|
+
const dayColumn = this.dayColumns[dayIndex];
|
|
738
|
+
const eventsContainer = dayColumn?.querySelector('.scheduler-events-container');
|
|
739
|
+
if (!eventsContainer)
|
|
740
|
+
return;
|
|
741
|
+
const previewEl = this.createElement('div', 'scheduler-event', 'preview');
|
|
742
|
+
const dayStart = new Date(previewEvent.start);
|
|
743
|
+
dayStart.setHours(0, 0, 0, 0);
|
|
744
|
+
const startMinutes = (previewEvent.start.getTime() - dayStart.getTime()) / (1000 * 60);
|
|
745
|
+
const endMinutes = (previewEvent.end.getTime() - dayStart.getTime()) / (1000 * 60);
|
|
746
|
+
const durationMinutes = endMinutes - startMinutes;
|
|
747
|
+
const slotMinutes = (options.slotDuration ?? 1800) / 60;
|
|
748
|
+
const top = (startMinutes / slotMinutes) * 40;
|
|
749
|
+
const height = Math.max((durationMinutes / slotMinutes) * 40, 20);
|
|
750
|
+
previewEl.style.top = `${top}px`;
|
|
751
|
+
previewEl.style.height = `${height}px`;
|
|
752
|
+
previewEl.style.left = '0';
|
|
753
|
+
previewEl.style.width = '100%';
|
|
754
|
+
eventsContainer.appendChild(previewEl);
|
|
755
|
+
}
|
|
756
|
+
updateGreyedSlots() {
|
|
757
|
+
const { dragState, previewEvent, options, date } = this.state;
|
|
758
|
+
// Clear all greyed slots
|
|
759
|
+
this.slotElements.forEach((el) => el.classList.remove('greyed'));
|
|
760
|
+
if (!dragState || !previewEvent)
|
|
761
|
+
return;
|
|
762
|
+
const days = dateService.getWeekDays(date, options.firstDayOfWeek);
|
|
763
|
+
const slots = dateService.getTimeSlots(days[0], options.slotDuration, options.slotMinTime, options.slotMaxTime);
|
|
764
|
+
// Find affected slots
|
|
765
|
+
for (let dayIndex = 0; dayIndex < days.length; dayIndex++) {
|
|
766
|
+
const day = days[dayIndex];
|
|
767
|
+
if (!dateService.isSameDay(day, previewEvent.start) &&
|
|
768
|
+
!dateService.isSameDay(day, previewEvent.end)) {
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
for (let slotIndex = 0; slotIndex < slots.length; slotIndex++) {
|
|
772
|
+
const slotTemplate = slots[slotIndex];
|
|
773
|
+
const slotStart = new Date(day);
|
|
774
|
+
slotStart.setHours(slotTemplate.start.getHours(), slotTemplate.start.getMinutes(), 0, 0);
|
|
775
|
+
const slotEnd = new Date(day);
|
|
776
|
+
slotEnd.setHours(slotTemplate.end.getHours(), slotTemplate.end.getMinutes(), 0, 0);
|
|
777
|
+
// Check if slot overlaps with preview event
|
|
778
|
+
if (slotStart < previewEvent.end && slotEnd > previewEvent.start) {
|
|
779
|
+
const key = `${dayIndex}-${slotIndex}`;
|
|
780
|
+
const slotEl = this.slotElements.get(key);
|
|
781
|
+
if (slotEl) {
|
|
782
|
+
slotEl.classList.add('greyed');
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
renderNowIndicator(days, slots) {
|
|
789
|
+
if (!this.state.options.nowIndicator)
|
|
790
|
+
return;
|
|
791
|
+
const now = new Date();
|
|
792
|
+
const todayIndex = days.findIndex((d) => dateService.isSameDay(d, now));
|
|
793
|
+
if (todayIndex === -1)
|
|
794
|
+
return;
|
|
795
|
+
const dayColumn = this.dayColumns[todayIndex];
|
|
796
|
+
if (!dayColumn)
|
|
797
|
+
return;
|
|
798
|
+
// Calculate position
|
|
799
|
+
const dayStart = new Date(now);
|
|
800
|
+
dayStart.setHours(0, 0, 0, 0);
|
|
801
|
+
const minutesFromMidnight = (now.getTime() - dayStart.getTime()) / (1000 * 60);
|
|
802
|
+
const slotMinutes = (this.state.options.slotDuration ?? 1800) / 60;
|
|
803
|
+
const top = (minutesFromMidnight / slotMinutes) * 40;
|
|
804
|
+
const indicator = this.createElement('div', 'scheduler-now-indicator');
|
|
805
|
+
indicator.style.top = `${top}px`;
|
|
806
|
+
dayColumn.appendChild(indicator);
|
|
807
|
+
}
|
|
808
|
+
updateNowIndicator() {
|
|
809
|
+
if (!this.state.options.nowIndicator)
|
|
810
|
+
return;
|
|
811
|
+
const { date, options } = this.state;
|
|
812
|
+
const days = dateService.getWeekDays(date, options.firstDayOfWeek);
|
|
813
|
+
const now = new Date();
|
|
814
|
+
const todayIndex = days.findIndex((d) => dateService.isSameDay(d, now));
|
|
815
|
+
if (todayIndex === -1)
|
|
816
|
+
return;
|
|
817
|
+
const dayColumn = this.dayColumns[todayIndex];
|
|
818
|
+
if (!dayColumn)
|
|
819
|
+
return;
|
|
820
|
+
// Find existing indicator
|
|
821
|
+
const existingIndicator = dayColumn.querySelector('.scheduler-now-indicator');
|
|
822
|
+
// Calculate new position
|
|
823
|
+
const dayStart = new Date(now);
|
|
824
|
+
dayStart.setHours(0, 0, 0, 0);
|
|
825
|
+
const minutesFromMidnight = (now.getTime() - dayStart.getTime()) / (1000 * 60);
|
|
826
|
+
const slotMinutes = (options.slotDuration ?? 1800) / 60;
|
|
827
|
+
const top = (minutesFromMidnight / slotMinutes) * 40;
|
|
828
|
+
if (existingIndicator) {
|
|
829
|
+
// Update position of existing indicator
|
|
830
|
+
existingIndicator.style.top = `${top}px`;
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
// Create new indicator if it doesn't exist
|
|
834
|
+
const indicator = this.createElement('div', 'scheduler-now-indicator');
|
|
835
|
+
indicator.style.top = `${top}px`;
|
|
836
|
+
dayColumn.appendChild(indicator);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
destroy() {
|
|
840
|
+
this.eventElements.clear();
|
|
841
|
+
this.slotElements.clear();
|
|
842
|
+
this.dayColumns = [];
|
|
843
|
+
this.clearContainer();
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Day view renderer
|
|
849
|
+
*/
|
|
850
|
+
class DayView extends BaseView {
|
|
851
|
+
constructor() {
|
|
852
|
+
super(...arguments);
|
|
853
|
+
this.eventsContainer = null;
|
|
854
|
+
this.slotElements = new Map();
|
|
855
|
+
this.dayColumn = null;
|
|
856
|
+
}
|
|
857
|
+
render() {
|
|
858
|
+
this.clearContainer();
|
|
859
|
+
this.container.classList.add('scheduler-day-view');
|
|
860
|
+
const { date, options } = this.state;
|
|
861
|
+
// Day header
|
|
862
|
+
const header = this.createElement('div', 'scheduler-day-headers');
|
|
863
|
+
// Time gutter space
|
|
864
|
+
const gutterSpace = this.createElement('div', 'scheduler-time-gutter-space');
|
|
865
|
+
gutterSpace.style.width = 'var(--scheduler-time-gutter-width)';
|
|
866
|
+
header.appendChild(gutterSpace);
|
|
867
|
+
const dayHeader = this.createElement('div', 'scheduler-day-header');
|
|
868
|
+
if (dateService.isToday(date)) {
|
|
869
|
+
dayHeader.classList.add('today');
|
|
870
|
+
}
|
|
871
|
+
const dayName = this.createElement('div', 'day-name');
|
|
872
|
+
dayName.textContent = dateService.getDayName(date, options.locale);
|
|
873
|
+
const dayNumber = this.createElement('div', 'day-number');
|
|
874
|
+
dayNumber.textContent = String(date.getDate());
|
|
875
|
+
const monthYear = this.createElement('div', 'month-year');
|
|
876
|
+
monthYear.textContent = dateService.formatDate(date, options.locale, {
|
|
877
|
+
month: 'long',
|
|
878
|
+
year: 'numeric',
|
|
879
|
+
});
|
|
880
|
+
monthYear.style.fontSize = '12px';
|
|
881
|
+
monthYear.style.color = '#666';
|
|
882
|
+
dayHeader.appendChild(dayName);
|
|
883
|
+
dayHeader.appendChild(dayNumber);
|
|
884
|
+
dayHeader.appendChild(monthYear);
|
|
885
|
+
header.appendChild(dayHeader);
|
|
886
|
+
this.container.appendChild(header);
|
|
887
|
+
// Time grid
|
|
888
|
+
const timeGrid = this.createElement('div', 'scheduler-time-grid');
|
|
889
|
+
// Time gutter
|
|
890
|
+
const timeGutter = this.createElement('div', 'scheduler-time-gutter');
|
|
891
|
+
const slots = dateService.getTimeSlots(date, options.slotDuration, options.slotMinTime, options.slotMaxTime);
|
|
892
|
+
for (const slot of slots) {
|
|
893
|
+
const label = this.createElement('div', 'scheduler-time-slot-label');
|
|
894
|
+
label.textContent = dateService.formatTime(slot.start, options.timeFormat);
|
|
895
|
+
timeGutter.appendChild(label);
|
|
896
|
+
}
|
|
897
|
+
timeGrid.appendChild(timeGutter);
|
|
898
|
+
// Day column
|
|
899
|
+
const daysContainer = this.createElement('div', 'scheduler-days-container');
|
|
900
|
+
this.dayColumn = this.createElement('div', 'scheduler-day-column');
|
|
901
|
+
const dayColumn = this.dayColumn;
|
|
902
|
+
// Create time slots
|
|
903
|
+
for (let slotIndex = 0; slotIndex < slots.length; slotIndex++) {
|
|
904
|
+
const slot = slots[slotIndex];
|
|
905
|
+
const slotEl = this.createElement('div', 'scheduler-time-slot');
|
|
906
|
+
this.setData(slotEl, {
|
|
907
|
+
slotIndex,
|
|
908
|
+
start: slot.start.toISOString(),
|
|
909
|
+
end: slot.end.toISOString(),
|
|
910
|
+
});
|
|
911
|
+
this.slotElements.set(slotIndex, slotEl);
|
|
912
|
+
dayColumn.appendChild(slotEl);
|
|
913
|
+
}
|
|
914
|
+
// Events container
|
|
915
|
+
this.eventsContainer = this.createElement('div', 'scheduler-events-container');
|
|
916
|
+
dayColumn.appendChild(this.eventsContainer);
|
|
917
|
+
daysContainer.appendChild(dayColumn);
|
|
918
|
+
timeGrid.appendChild(daysContainer);
|
|
919
|
+
this.container.appendChild(timeGrid);
|
|
920
|
+
// Render events
|
|
921
|
+
this.renderEvents();
|
|
922
|
+
// Render now indicator
|
|
923
|
+
if (dateService.isToday(date) && options.nowIndicator) {
|
|
924
|
+
this.renderNowIndicator(dayColumn);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
renderEvents() {
|
|
928
|
+
if (!this.eventsContainer)
|
|
929
|
+
return;
|
|
930
|
+
this.eventsContainer.innerHTML = '';
|
|
931
|
+
const { date, events, options } = this.state;
|
|
932
|
+
const dayStart = new Date(date);
|
|
933
|
+
dayStart.setHours(0, 0, 0, 0);
|
|
934
|
+
const dayEnd = new Date(date);
|
|
935
|
+
dayEnd.setHours(23, 59, 59, 999);
|
|
936
|
+
// Filter events for this day
|
|
937
|
+
const dayEvents = timelineService.filterByRange(events, dayStart, dayEnd);
|
|
938
|
+
// Split events into parts
|
|
939
|
+
const allParts = [];
|
|
940
|
+
for (const event of dayEvents) {
|
|
941
|
+
const { parts } = timelineService.splitInParts(event);
|
|
942
|
+
const dayParts = timelineService.filterPartsByRange(parts, dayStart, dayEnd);
|
|
943
|
+
allParts.push(...dayParts);
|
|
944
|
+
}
|
|
945
|
+
// Get timelened parts with track info
|
|
946
|
+
const timelinedParts = timelineService.getTimelinedParts(allParts);
|
|
947
|
+
// Render each event part
|
|
948
|
+
for (const { part, trackIndex, totalTracks, colspan } of timelinedParts) {
|
|
949
|
+
if (!part.event)
|
|
950
|
+
continue;
|
|
951
|
+
const eventEl = this.createEventElement(part, trackIndex, totalTracks, colspan, options.slotDuration ?? 1800);
|
|
952
|
+
this.eventsContainer.appendChild(eventEl);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
createEventElement(part, trackIndex, totalTracks, colspan, slotDuration) {
|
|
956
|
+
const event = part.event;
|
|
957
|
+
const eventEl = this.createElement('div', 'scheduler-event');
|
|
958
|
+
// Mark as selected if this is the selected event
|
|
959
|
+
if (this.state.selectedEvent?.id === event.id) {
|
|
960
|
+
eventEl.classList.add('selected');
|
|
961
|
+
}
|
|
962
|
+
// Calculate position
|
|
963
|
+
const dayStart = new Date(part.start);
|
|
964
|
+
dayStart.setHours(0, 0, 0, 0);
|
|
965
|
+
const startMinutes = (part.start.getTime() - dayStart.getTime()) / (1000 * 60);
|
|
966
|
+
const endMinutes = (part.end.getTime() - dayStart.getTime()) / (1000 * 60);
|
|
967
|
+
const durationMinutes = endMinutes - startMinutes;
|
|
968
|
+
const slotMinutes = slotDuration / 60;
|
|
969
|
+
const top = (startMinutes / slotMinutes) * 40;
|
|
970
|
+
const height = Math.max((durationMinutes / slotMinutes) * 40, 20);
|
|
971
|
+
// Calculate width based on tracks and colspan
|
|
972
|
+
// colspan allows events to span multiple columns when there's no blocking event
|
|
973
|
+
const leftPercent = (trackIndex / totalTracks) * 100;
|
|
974
|
+
const widthPercent = (colspan / totalTracks) * 100;
|
|
975
|
+
eventEl.style.top = `${top}px`;
|
|
976
|
+
eventEl.style.height = `${height}px`;
|
|
977
|
+
eventEl.style.left = `${leftPercent}%`;
|
|
978
|
+
eventEl.style.width = `calc(${widthPercent}% - 2px)`;
|
|
979
|
+
eventEl.style.backgroundColor = event.color ?? '#3788d8';
|
|
980
|
+
eventEl.style.color = event.textColor ?? getContrastColor(event.color ?? '#3788d8');
|
|
981
|
+
this.setData(eventEl, { eventId: event.id });
|
|
982
|
+
// Title
|
|
983
|
+
const title = this.createElement('div', 'event-title');
|
|
984
|
+
title.textContent = event.title;
|
|
985
|
+
eventEl.appendChild(title);
|
|
986
|
+
// Time
|
|
987
|
+
const timeEl = this.createElement('div', 'event-time');
|
|
988
|
+
timeEl.textContent = `${dateService.formatTime(part.start, this.state.options.timeFormat)} - ${dateService.formatTime(part.end, this.state.options.timeFormat)}`;
|
|
989
|
+
eventEl.appendChild(timeEl);
|
|
990
|
+
// Resize handles
|
|
991
|
+
if (event.resizable !== false) {
|
|
992
|
+
if (part.isStart) {
|
|
993
|
+
const topHandle = this.createElement('div', 'resize-handle', 'top');
|
|
994
|
+
this.setData(topHandle, { handle: 'start' });
|
|
995
|
+
eventEl.appendChild(topHandle);
|
|
996
|
+
}
|
|
997
|
+
if (part.isEnd) {
|
|
998
|
+
const bottomHandle = this.createElement('div', 'resize-handle', 'bottom');
|
|
999
|
+
this.setData(bottomHandle, { handle: 'end' });
|
|
1000
|
+
eventEl.appendChild(bottomHandle);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
return eventEl;
|
|
1004
|
+
}
|
|
1005
|
+
renderNowIndicator(dayColumn) {
|
|
1006
|
+
const now = new Date();
|
|
1007
|
+
const dayStart = new Date(now);
|
|
1008
|
+
dayStart.setHours(0, 0, 0, 0);
|
|
1009
|
+
const minutesFromMidnight = (now.getTime() - dayStart.getTime()) / (1000 * 60);
|
|
1010
|
+
const slotMinutes = (this.state.options.slotDuration ?? 1800) / 60;
|
|
1011
|
+
const top = (minutesFromMidnight / slotMinutes) * 40;
|
|
1012
|
+
const indicator = this.createElement('div', 'scheduler-now-indicator');
|
|
1013
|
+
indicator.style.top = `${top}px`;
|
|
1014
|
+
dayColumn.appendChild(indicator);
|
|
1015
|
+
}
|
|
1016
|
+
updateNowIndicator() {
|
|
1017
|
+
if (!this.dayColumn)
|
|
1018
|
+
return;
|
|
1019
|
+
if (!this.state.options.nowIndicator)
|
|
1020
|
+
return;
|
|
1021
|
+
if (!dateService.isToday(this.state.date))
|
|
1022
|
+
return;
|
|
1023
|
+
// Find existing indicator
|
|
1024
|
+
const existingIndicator = this.dayColumn.querySelector('.scheduler-now-indicator');
|
|
1025
|
+
// Calculate new position
|
|
1026
|
+
const now = new Date();
|
|
1027
|
+
const dayStart = new Date(now);
|
|
1028
|
+
dayStart.setHours(0, 0, 0, 0);
|
|
1029
|
+
const minutesFromMidnight = (now.getTime() - dayStart.getTime()) / (1000 * 60);
|
|
1030
|
+
const slotMinutes = (this.state.options.slotDuration ?? 1800) / 60;
|
|
1031
|
+
const top = (minutesFromMidnight / slotMinutes) * 40;
|
|
1032
|
+
if (existingIndicator) {
|
|
1033
|
+
// Update position of existing indicator
|
|
1034
|
+
existingIndicator.style.top = `${top}px`;
|
|
1035
|
+
}
|
|
1036
|
+
else {
|
|
1037
|
+
// Create new indicator if it doesn't exist
|
|
1038
|
+
this.renderNowIndicator(this.dayColumn);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
update(state) {
|
|
1042
|
+
const dateChanged = this.state.date.getTime() !== state.date.getTime();
|
|
1043
|
+
const optionsChanged = this.optionsRequireRerender(this.state.options, state.options);
|
|
1044
|
+
this.state = state;
|
|
1045
|
+
// If date or relevant options changed, we need to re-render the entire view
|
|
1046
|
+
if (dateChanged || optionsChanged) {
|
|
1047
|
+
this.render();
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
// Update greyed slots
|
|
1051
|
+
this.updateGreyedSlots();
|
|
1052
|
+
// Re-render events
|
|
1053
|
+
this.renderEvents();
|
|
1054
|
+
// Render preview event
|
|
1055
|
+
this.renderPreviewEvent();
|
|
1056
|
+
}
|
|
1057
|
+
optionsRequireRerender(oldOpts, newOpts) {
|
|
1058
|
+
return oldOpts.slotDuration !== newOpts.slotDuration ||
|
|
1059
|
+
oldOpts.timeFormat !== newOpts.timeFormat ||
|
|
1060
|
+
oldOpts.slotMinTime !== newOpts.slotMinTime ||
|
|
1061
|
+
oldOpts.slotMaxTime !== newOpts.slotMaxTime ||
|
|
1062
|
+
oldOpts.locale !== newOpts.locale;
|
|
1063
|
+
}
|
|
1064
|
+
updateGreyedSlots() {
|
|
1065
|
+
const { dragState, previewEvent, options, date } = this.state;
|
|
1066
|
+
// Clear all greyed slots
|
|
1067
|
+
this.slotElements.forEach((el) => el.classList.remove('greyed'));
|
|
1068
|
+
if (!dragState || !previewEvent)
|
|
1069
|
+
return;
|
|
1070
|
+
if (!dateService.isSameDay(date, previewEvent.start))
|
|
1071
|
+
return;
|
|
1072
|
+
const slots = dateService.getTimeSlots(date, options.slotDuration, options.slotMinTime, options.slotMaxTime);
|
|
1073
|
+
for (let slotIndex = 0; slotIndex < slots.length; slotIndex++) {
|
|
1074
|
+
const slot = slots[slotIndex];
|
|
1075
|
+
// Check if slot overlaps with preview event
|
|
1076
|
+
if (slot.start < previewEvent.end && slot.end > previewEvent.start) {
|
|
1077
|
+
const slotEl = this.slotElements.get(slotIndex);
|
|
1078
|
+
if (slotEl) {
|
|
1079
|
+
slotEl.classList.add('greyed');
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
renderPreviewEvent() {
|
|
1085
|
+
if (!this.eventsContainer)
|
|
1086
|
+
return;
|
|
1087
|
+
// Remove existing preview
|
|
1088
|
+
const existingPreview = this.eventsContainer.querySelector('.scheduler-event.preview');
|
|
1089
|
+
if (existingPreview) {
|
|
1090
|
+
existingPreview.remove();
|
|
1091
|
+
}
|
|
1092
|
+
const { previewEvent, options, date } = this.state;
|
|
1093
|
+
if (!previewEvent)
|
|
1094
|
+
return;
|
|
1095
|
+
if (!dateService.isSameDay(date, previewEvent.start))
|
|
1096
|
+
return;
|
|
1097
|
+
const previewEl = this.createElement('div', 'scheduler-event', 'preview');
|
|
1098
|
+
const dayStart = new Date(previewEvent.start);
|
|
1099
|
+
dayStart.setHours(0, 0, 0, 0);
|
|
1100
|
+
const startMinutes = (previewEvent.start.getTime() - dayStart.getTime()) / (1000 * 60);
|
|
1101
|
+
const endMinutes = (previewEvent.end.getTime() - dayStart.getTime()) / (1000 * 60);
|
|
1102
|
+
const durationMinutes = endMinutes - startMinutes;
|
|
1103
|
+
const slotMinutes = (options.slotDuration ?? 1800) / 60;
|
|
1104
|
+
const top = (startMinutes / slotMinutes) * 40;
|
|
1105
|
+
const height = Math.max((durationMinutes / slotMinutes) * 40, 20);
|
|
1106
|
+
previewEl.style.top = `${top}px`;
|
|
1107
|
+
previewEl.style.height = `${height}px`;
|
|
1108
|
+
previewEl.style.left = '0';
|
|
1109
|
+
previewEl.style.width = '100%';
|
|
1110
|
+
this.eventsContainer.appendChild(previewEl);
|
|
1111
|
+
}
|
|
1112
|
+
destroy() {
|
|
1113
|
+
this.eventsContainer = null;
|
|
1114
|
+
this.dayColumn = null;
|
|
1115
|
+
this.slotElements.clear();
|
|
1116
|
+
this.clearContainer();
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Timeline view renderer
|
|
1122
|
+
*/
|
|
1123
|
+
class TimelineView extends BaseView {
|
|
1124
|
+
constructor() {
|
|
1125
|
+
super(...arguments);
|
|
1126
|
+
this.rowElements = new Map();
|
|
1127
|
+
this.slotWidth = 50;
|
|
1128
|
+
}
|
|
1129
|
+
render() {
|
|
1130
|
+
this.clearContainer();
|
|
1131
|
+
this.container.classList.add('scheduler-timeline-view');
|
|
1132
|
+
const { date, options, resources, collapsedGroups } = this.state;
|
|
1133
|
+
const days = dateService.getWeekDays(date, options.firstDayOfWeek);
|
|
1134
|
+
// Create timeline structure
|
|
1135
|
+
const timeline = this.createElement('div', 'scheduler-timeline');
|
|
1136
|
+
// Header
|
|
1137
|
+
const header = this.createElement('div', 'scheduler-timeline-header');
|
|
1138
|
+
// Resource column header
|
|
1139
|
+
const resourceHeader = this.createElement('div', 'scheduler-resource-header');
|
|
1140
|
+
resourceHeader.textContent = 'Resources';
|
|
1141
|
+
header.appendChild(resourceHeader);
|
|
1142
|
+
// Time slots header
|
|
1143
|
+
const slotsHeader = this.createElement('div', 'scheduler-timeline-slots-header');
|
|
1144
|
+
for (const day of days) {
|
|
1145
|
+
const slots = dateService.getTimeSlots(day, options.slotDuration, options.slotMinTime, options.slotMaxTime);
|
|
1146
|
+
// Day header spanning multiple slots
|
|
1147
|
+
const daySlots = slots.length;
|
|
1148
|
+
const dayHeader = this.createElement('div', 'scheduler-timeline-slot-header');
|
|
1149
|
+
dayHeader.style.width = `${daySlots * this.slotWidth}px`;
|
|
1150
|
+
dayHeader.textContent = dateService.formatDateWithWeekday(day, options.locale);
|
|
1151
|
+
dayHeader.style.borderBottom = '1px solid var(--scheduler-border-color)';
|
|
1152
|
+
slotsHeader.appendChild(dayHeader);
|
|
1153
|
+
}
|
|
1154
|
+
header.appendChild(slotsHeader);
|
|
1155
|
+
timeline.appendChild(header);
|
|
1156
|
+
// Time labels row
|
|
1157
|
+
const timeLabelRow = this.createElement('div', 'scheduler-timeline-header');
|
|
1158
|
+
const emptyCell = this.createElement('div', 'scheduler-resource-header');
|
|
1159
|
+
emptyCell.style.borderBottom = '1px solid var(--scheduler-border-color)';
|
|
1160
|
+
timeLabelRow.appendChild(emptyCell);
|
|
1161
|
+
const timeLabelsContainer = this.createElement('div', 'scheduler-timeline-slots-header');
|
|
1162
|
+
for (const day of days) {
|
|
1163
|
+
const slots = dateService.getTimeSlots(day, options.slotDuration, options.slotMinTime, options.slotMaxTime);
|
|
1164
|
+
for (const slot of slots) {
|
|
1165
|
+
const slotHeader = this.createElement('div', 'scheduler-timeline-slot-header');
|
|
1166
|
+
slotHeader.style.width = `${this.slotWidth}px`;
|
|
1167
|
+
slotHeader.textContent = dateService.formatTime(slot.start, options.timeFormat);
|
|
1168
|
+
slotHeader.style.fontSize = '10px';
|
|
1169
|
+
timeLabelsContainer.appendChild(slotHeader);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
timeLabelRow.appendChild(timeLabelsContainer);
|
|
1173
|
+
timeline.appendChild(timeLabelRow);
|
|
1174
|
+
// Body
|
|
1175
|
+
const body = this.createElement('div', 'scheduler-timeline-body');
|
|
1176
|
+
// Flatten resources
|
|
1177
|
+
const flattened = resourceService.flatten(resources, collapsedGroups);
|
|
1178
|
+
for (const flat of flattened) {
|
|
1179
|
+
if (!flat.visible)
|
|
1180
|
+
continue;
|
|
1181
|
+
const row = this.createResourceRow(flat, days);
|
|
1182
|
+
body.appendChild(row);
|
|
1183
|
+
}
|
|
1184
|
+
timeline.appendChild(body);
|
|
1185
|
+
this.container.appendChild(timeline);
|
|
1186
|
+
// Render events
|
|
1187
|
+
this.renderEvents(days);
|
|
1188
|
+
}
|
|
1189
|
+
createResourceRow(flat, days) {
|
|
1190
|
+
const { options } = this.state;
|
|
1191
|
+
const row = this.createElement('div', 'scheduler-timeline-row');
|
|
1192
|
+
if (isResourceGroup(flat.item)) {
|
|
1193
|
+
row.classList.add('group');
|
|
1194
|
+
}
|
|
1195
|
+
// Resource cell
|
|
1196
|
+
const resourceCell = this.createElement('div', 'scheduler-resource-cell');
|
|
1197
|
+
resourceCell.style.paddingLeft = `${8 + flat.depth * 16}px`;
|
|
1198
|
+
if (isResourceGroup(flat.item)) {
|
|
1199
|
+
const toggle = this.createElement('span', 'expand-toggle');
|
|
1200
|
+
toggle.textContent = this.state.collapsedGroups.has(flat.item.id) ? '▶' : '▼';
|
|
1201
|
+
this.setData(toggle, { groupId: flat.item.id });
|
|
1202
|
+
resourceCell.appendChild(toggle);
|
|
1203
|
+
}
|
|
1204
|
+
const title = this.createElement('span');
|
|
1205
|
+
title.textContent = flat.item.title;
|
|
1206
|
+
resourceCell.appendChild(title);
|
|
1207
|
+
row.appendChild(resourceCell);
|
|
1208
|
+
// Slots container
|
|
1209
|
+
const slotsContainer = this.createElement('div', 'scheduler-timeline-slots');
|
|
1210
|
+
for (const day of days) {
|
|
1211
|
+
const slots = dateService.getTimeSlots(day, options.slotDuration, options.slotMinTime, options.slotMaxTime);
|
|
1212
|
+
for (const slot of slots) {
|
|
1213
|
+
const slotEl = this.createElement('div', 'scheduler-timeline-slot');
|
|
1214
|
+
slotEl.style.width = `${this.slotWidth}px`;
|
|
1215
|
+
this.setData(slotEl, {
|
|
1216
|
+
resourceId: flat.item.id,
|
|
1217
|
+
start: slot.start.toISOString(),
|
|
1218
|
+
end: slot.end.toISOString(),
|
|
1219
|
+
});
|
|
1220
|
+
slotsContainer.appendChild(slotEl);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
// Events container for this row
|
|
1224
|
+
if (isResource(flat.item)) {
|
|
1225
|
+
const eventsContainer = this.createElement('div', 'scheduler-timeline-events');
|
|
1226
|
+
slotsContainer.appendChild(eventsContainer);
|
|
1227
|
+
}
|
|
1228
|
+
row.appendChild(slotsContainer);
|
|
1229
|
+
this.rowElements.set(flat.item.id, row);
|
|
1230
|
+
return row;
|
|
1231
|
+
}
|
|
1232
|
+
renderEvents(days) {
|
|
1233
|
+
const { events, resources, options, collapsedGroups } = this.state;
|
|
1234
|
+
const weekStart = days[0];
|
|
1235
|
+
const weekEnd = new Date(days[6]);
|
|
1236
|
+
weekEnd.setHours(23, 59, 59, 999);
|
|
1237
|
+
// Get total slots per day
|
|
1238
|
+
const slotsPerDay = dateService.getTimeSlots(days[0], options.slotDuration, options.slotMinTime, options.slotMaxTime).length;
|
|
1239
|
+
const totalSlots = slotsPerDay * 7;
|
|
1240
|
+
const totalWidth = totalSlots * this.slotWidth;
|
|
1241
|
+
// Get all resources
|
|
1242
|
+
const allResources = resourceService.getAllResources(resources);
|
|
1243
|
+
for (const resource of allResources) {
|
|
1244
|
+
const row = this.rowElements.get(resource.id);
|
|
1245
|
+
if (!row)
|
|
1246
|
+
continue;
|
|
1247
|
+
const eventsContainer = row.querySelector('.scheduler-timeline-events');
|
|
1248
|
+
if (!eventsContainer)
|
|
1249
|
+
continue;
|
|
1250
|
+
// Clear existing events
|
|
1251
|
+
eventsContainer.innerHTML = '';
|
|
1252
|
+
// Get events for this resource
|
|
1253
|
+
const resourceEvents = (resource.events ?? []).filter((e) => e.start < weekEnd && e.end > weekStart);
|
|
1254
|
+
// Create event parts for layout (don't split into daily parts for timeline view)
|
|
1255
|
+
// For timeline view, treat each event as a single entity for layout purposes
|
|
1256
|
+
const allParts = resourceEvents.map((event) => ({
|
|
1257
|
+
id: event.id,
|
|
1258
|
+
event: event,
|
|
1259
|
+
start: event.start,
|
|
1260
|
+
end: event.end,
|
|
1261
|
+
isStart: true,
|
|
1262
|
+
isEnd: true,
|
|
1263
|
+
dayIndex: 0,
|
|
1264
|
+
totalDays: 1,
|
|
1265
|
+
}));
|
|
1266
|
+
// Get timelened parts with track info (uses colspan algorithm)
|
|
1267
|
+
const timelinedParts = timelineService.getTimelinedParts(allParts);
|
|
1268
|
+
// Render each event part
|
|
1269
|
+
for (const { part, trackIndex, totalTracks, colspan } of timelinedParts) {
|
|
1270
|
+
if (!part.event)
|
|
1271
|
+
continue;
|
|
1272
|
+
const eventEl = this.createEventElement(part.event, trackIndex, totalTracks, colspan, weekStart, totalWidth, options.slotDuration ?? 1800);
|
|
1273
|
+
eventsContainer.appendChild(eventEl);
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
createEventElement(event, trackIndex, totalTracks, colspan, viewStart, totalWidth, slotDuration) {
|
|
1278
|
+
const eventEl = this.createElement('div', 'scheduler-timeline-event');
|
|
1279
|
+
// Mark as selected if this is the selected event
|
|
1280
|
+
if (this.state.selectedEvent?.id === event.id) {
|
|
1281
|
+
eventEl.classList.add('selected');
|
|
1282
|
+
}
|
|
1283
|
+
// Clamp event to view bounds
|
|
1284
|
+
const eventStart = Math.max(event.start.getTime(), viewStart.getTime());
|
|
1285
|
+
const viewEndTime = viewStart.getTime() + 7 * 24 * 60 * 60 * 1000;
|
|
1286
|
+
const eventEnd = Math.min(event.end.getTime(), viewEndTime);
|
|
1287
|
+
// Calculate position
|
|
1288
|
+
const startOffset = eventStart - viewStart.getTime();
|
|
1289
|
+
const duration = eventEnd - eventStart;
|
|
1290
|
+
const viewDuration = viewEndTime - viewStart.getTime();
|
|
1291
|
+
const left = (startOffset / viewDuration) * totalWidth;
|
|
1292
|
+
const width = Math.max((duration / viewDuration) * totalWidth, 20);
|
|
1293
|
+
// Calculate vertical position based on track and colspan
|
|
1294
|
+
// colspan allows events to span multiple tracks when there's no blocking event
|
|
1295
|
+
const top = (trackIndex / totalTracks) * 100;
|
|
1296
|
+
const heightPercent = (colspan / totalTracks) * 100;
|
|
1297
|
+
eventEl.style.left = `${left}px`;
|
|
1298
|
+
eventEl.style.width = `${width}px`;
|
|
1299
|
+
eventEl.style.top = `${top}%`;
|
|
1300
|
+
eventEl.style.height = `${heightPercent}%`;
|
|
1301
|
+
eventEl.style.backgroundColor = event.color ?? '#3788d8';
|
|
1302
|
+
eventEl.style.color = event.textColor ?? getContrastColor(event.color ?? '#3788d8');
|
|
1303
|
+
this.setData(eventEl, { eventId: event.id });
|
|
1304
|
+
eventEl.textContent = event.title;
|
|
1305
|
+
return eventEl;
|
|
1306
|
+
}
|
|
1307
|
+
update(state) {
|
|
1308
|
+
const dateChanged = this.state.date.getTime() !== state.date.getTime();
|
|
1309
|
+
const optionsChanged = this.optionsRequireRerender(this.state.options, state.options);
|
|
1310
|
+
this.state = state;
|
|
1311
|
+
// If date or relevant options changed, we need to re-render the entire view
|
|
1312
|
+
if (dateChanged || optionsChanged) {
|
|
1313
|
+
this.render();
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
// Update greyed slots
|
|
1317
|
+
this.updateGreyedSlots();
|
|
1318
|
+
// Re-render events
|
|
1319
|
+
const days = dateService.getWeekDays(state.date, state.options.firstDayOfWeek);
|
|
1320
|
+
this.renderEvents(days);
|
|
1321
|
+
}
|
|
1322
|
+
optionsRequireRerender(oldOpts, newOpts) {
|
|
1323
|
+
return oldOpts.slotDuration !== newOpts.slotDuration ||
|
|
1324
|
+
oldOpts.timeFormat !== newOpts.timeFormat ||
|
|
1325
|
+
oldOpts.firstDayOfWeek !== newOpts.firstDayOfWeek ||
|
|
1326
|
+
oldOpts.slotMinTime !== newOpts.slotMinTime ||
|
|
1327
|
+
oldOpts.slotMaxTime !== newOpts.slotMaxTime ||
|
|
1328
|
+
oldOpts.locale !== newOpts.locale;
|
|
1329
|
+
}
|
|
1330
|
+
updateGreyedSlots() {
|
|
1331
|
+
const { dragState, previewEvent } = this.state;
|
|
1332
|
+
// Clear all greyed slots
|
|
1333
|
+
const allSlots = this.container.querySelectorAll('.scheduler-timeline-slot');
|
|
1334
|
+
allSlots.forEach((slot) => slot.classList.remove('greyed'));
|
|
1335
|
+
if (!dragState || !previewEvent)
|
|
1336
|
+
return;
|
|
1337
|
+
// Grey out slots that overlap with the preview
|
|
1338
|
+
allSlots.forEach((slot) => {
|
|
1339
|
+
const slotStart = new Date(slot.dataset['start'] ?? '');
|
|
1340
|
+
const slotEnd = new Date(slot.dataset['end'] ?? '');
|
|
1341
|
+
if (slotStart < previewEvent.end && slotEnd > previewEvent.start) {
|
|
1342
|
+
slot.classList.add('greyed');
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
destroy() {
|
|
1347
|
+
this.rowElements.clear();
|
|
1348
|
+
this.clearContainer();
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// AUTO-GENERATED — do not edit by hand.
|
|
1353
|
+
// Source: scheduler.styles.scss
|
|
1354
|
+
// Regenerate with the codegen-wc Nx target.
|
|
1355
|
+
const schedulerStyles = unsafeCSS(`:host {
|
|
1356
|
+
display: block;
|
|
1357
|
+
height: 100%;
|
|
1358
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
|
|
1359
|
+
font-size: 14px;
|
|
1360
|
+
--scheduler-border-color: #ddd;
|
|
1361
|
+
--scheduler-header-bg: #f8f9fa;
|
|
1362
|
+
--scheduler-today-bg: #fff3cd;
|
|
1363
|
+
--scheduler-slot-height: 40px;
|
|
1364
|
+
--scheduler-time-gutter-width: 80px;
|
|
1365
|
+
--scheduler-event-border-radius: 4px;
|
|
1366
|
+
--scheduler-now-indicator-color: #dc3545;
|
|
1367
|
+
--scheduler-preview-bg: rgba(0, 123, 255, 0.3);
|
|
1368
|
+
--scheduler-greyed-slot-bg: rgba(0, 0, 0, 0.1);
|
|
1369
|
+
--scheduler-column-min-width: 120px;
|
|
1370
|
+
--scheduler-touch-hold-duration: 500ms;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
* {
|
|
1374
|
+
box-sizing: border-box;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
.scheduler-container {
|
|
1378
|
+
display: flex;
|
|
1379
|
+
flex-direction: column;
|
|
1380
|
+
height: 100%;
|
|
1381
|
+
border: 1px solid var(--scheduler-border-color);
|
|
1382
|
+
border-radius: 4px;
|
|
1383
|
+
overflow: hidden;
|
|
1384
|
+
background: #fff;
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
/* Header */
|
|
1388
|
+
.scheduler-header {
|
|
1389
|
+
display: flex;
|
|
1390
|
+
align-items: center;
|
|
1391
|
+
justify-content: space-between;
|
|
1392
|
+
padding: 8px 12px;
|
|
1393
|
+
background: var(--scheduler-header-bg);
|
|
1394
|
+
border-bottom: 1px solid var(--scheduler-border-color);
|
|
1395
|
+
flex-shrink: 0;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
.scheduler-nav {
|
|
1399
|
+
display: flex;
|
|
1400
|
+
align-items: center;
|
|
1401
|
+
gap: 8px;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
.scheduler-nav button {
|
|
1405
|
+
padding: 6px 12px;
|
|
1406
|
+
border: 1px solid var(--scheduler-border-color);
|
|
1407
|
+
border-radius: 4px;
|
|
1408
|
+
background: #fff;
|
|
1409
|
+
cursor: pointer;
|
|
1410
|
+
font-size: 14px;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
.scheduler-nav button:hover {
|
|
1414
|
+
background: #e9ecef;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
.scheduler-title {
|
|
1418
|
+
font-size: 18px;
|
|
1419
|
+
font-weight: 600;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
.scheduler-view-switcher {
|
|
1423
|
+
display: flex;
|
|
1424
|
+
gap: 4px;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
.scheduler-view-switcher button {
|
|
1428
|
+
padding: 6px 12px;
|
|
1429
|
+
border: 1px solid var(--scheduler-border-color);
|
|
1430
|
+
border-radius: 4px;
|
|
1431
|
+
background: #fff;
|
|
1432
|
+
cursor: pointer;
|
|
1433
|
+
font-size: 13px;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
.scheduler-view-switcher button:hover {
|
|
1437
|
+
background: #e9ecef;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
.scheduler-view-switcher button.active {
|
|
1441
|
+
background: #0d6efd;
|
|
1442
|
+
color: #fff;
|
|
1443
|
+
border-color: #0d6efd;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/* Main content area */
|
|
1447
|
+
.scheduler-body {
|
|
1448
|
+
display: flex;
|
|
1449
|
+
flex: 1;
|
|
1450
|
+
overflow: hidden;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
.scheduler-sidebar {
|
|
1454
|
+
width: var(--scheduler-time-gutter-width);
|
|
1455
|
+
flex-shrink: 0;
|
|
1456
|
+
border-right: 1px solid var(--scheduler-border-color);
|
|
1457
|
+
background: var(--scheduler-header-bg);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
.scheduler-content {
|
|
1461
|
+
flex: 1;
|
|
1462
|
+
overflow: auto;
|
|
1463
|
+
position: relative;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/* Week/Day View Grid */
|
|
1467
|
+
.scheduler-grid {
|
|
1468
|
+
display: grid;
|
|
1469
|
+
min-width: 100%;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
.scheduler-day-headers {
|
|
1473
|
+
display: flex;
|
|
1474
|
+
border-bottom: 1px solid var(--scheduler-border-color);
|
|
1475
|
+
background: var(--scheduler-header-bg);
|
|
1476
|
+
position: sticky;
|
|
1477
|
+
top: 0;
|
|
1478
|
+
z-index: 10;
|
|
1479
|
+
min-width: fit-content;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
.scheduler-day-header {
|
|
1483
|
+
flex: 1 0 var(--scheduler-column-min-width);
|
|
1484
|
+
min-width: var(--scheduler-column-min-width);
|
|
1485
|
+
text-align: center;
|
|
1486
|
+
padding: 8px 4px;
|
|
1487
|
+
border-right: 1px solid var(--scheduler-border-color);
|
|
1488
|
+
font-weight: 500;
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1491
|
+
.scheduler-day-header:last-child {
|
|
1492
|
+
border-right: none;
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
.scheduler-day-header.today {
|
|
1496
|
+
background: var(--scheduler-today-bg);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
.scheduler-day-header .day-name {
|
|
1500
|
+
font-size: 12px;
|
|
1501
|
+
color: #666;
|
|
1502
|
+
text-transform: uppercase;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
.scheduler-day-header .day-number {
|
|
1506
|
+
font-size: 20px;
|
|
1507
|
+
font-weight: 600;
|
|
1508
|
+
margin-top: 2px;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
.scheduler-time-grid {
|
|
1512
|
+
display: flex;
|
|
1513
|
+
position: relative;
|
|
1514
|
+
min-width: fit-content;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
.scheduler-time-gutter {
|
|
1518
|
+
width: var(--scheduler-time-gutter-width);
|
|
1519
|
+
flex-shrink: 0;
|
|
1520
|
+
border-right: 1px solid var(--scheduler-border-color);
|
|
1521
|
+
background: var(--scheduler-header-bg);
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
.scheduler-time-slot-label {
|
|
1525
|
+
height: var(--scheduler-slot-height);
|
|
1526
|
+
padding: 0 8px;
|
|
1527
|
+
font-size: 12px;
|
|
1528
|
+
color: #666;
|
|
1529
|
+
text-align: right;
|
|
1530
|
+
position: relative;
|
|
1531
|
+
top: -8px;
|
|
1532
|
+
white-space: nowrap;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
.scheduler-days-container {
|
|
1536
|
+
display: flex;
|
|
1537
|
+
flex: 1;
|
|
1538
|
+
position: relative;
|
|
1539
|
+
min-width: fit-content;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
.scheduler-day-column {
|
|
1543
|
+
flex: 1 0 var(--scheduler-column-min-width);
|
|
1544
|
+
min-width: var(--scheduler-column-min-width);
|
|
1545
|
+
position: relative;
|
|
1546
|
+
border-right: 1px solid var(--scheduler-border-color);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
.scheduler-day-column:last-child {
|
|
1550
|
+
border-right: none;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
.scheduler-time-slot {
|
|
1554
|
+
height: var(--scheduler-slot-height);
|
|
1555
|
+
border-bottom: 1px solid #eee;
|
|
1556
|
+
position: relative;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
.scheduler-time-slot:nth-child(2n) {
|
|
1560
|
+
border-bottom-color: var(--scheduler-border-color);
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
.scheduler-time-slot.greyed {
|
|
1564
|
+
background: var(--scheduler-greyed-slot-bg);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
/* Events */
|
|
1568
|
+
.scheduler-events-container {
|
|
1569
|
+
position: absolute;
|
|
1570
|
+
top: 0;
|
|
1571
|
+
left: 0;
|
|
1572
|
+
right: 0;
|
|
1573
|
+
bottom: 0;
|
|
1574
|
+
pointer-events: none;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
.scheduler-event {
|
|
1578
|
+
position: absolute;
|
|
1579
|
+
border-radius: var(--scheduler-event-border-radius);
|
|
1580
|
+
padding: 2px 4px;
|
|
1581
|
+
font-size: 12px;
|
|
1582
|
+
overflow: hidden;
|
|
1583
|
+
cursor: pointer;
|
|
1584
|
+
pointer-events: auto;
|
|
1585
|
+
border-left: 3px solid rgba(0, 0, 0, 0.2);
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
/* Prevent browser from handling touch gestures on events (enables drag) */
|
|
1589
|
+
.scheduler-event:not(.preview) {
|
|
1590
|
+
touch-action: none;
|
|
1591
|
+
-ms-touch-action: none;
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
.scheduler-event:hover {
|
|
1595
|
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
.scheduler-event.selected {
|
|
1599
|
+
box-shadow: 0 0 0 3px #212529;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
.scheduler-event .event-title {
|
|
1603
|
+
font-weight: 500;
|
|
1604
|
+
white-space: nowrap;
|
|
1605
|
+
overflow: hidden;
|
|
1606
|
+
text-overflow: ellipsis;
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
.scheduler-event .event-time {
|
|
1610
|
+
font-size: 11px;
|
|
1611
|
+
opacity: 0.8;
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
.scheduler-event.preview {
|
|
1615
|
+
background: var(--scheduler-preview-bg);
|
|
1616
|
+
border: 2px dashed #0d6efd;
|
|
1617
|
+
pointer-events: none;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
/* Resize handles */
|
|
1621
|
+
.scheduler-event .resize-handle {
|
|
1622
|
+
position: absolute;
|
|
1623
|
+
left: 0;
|
|
1624
|
+
right: 0;
|
|
1625
|
+
height: 8px;
|
|
1626
|
+
cursor: ns-resize;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
.scheduler-event .resize-handle.top {
|
|
1630
|
+
top: 0;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
.scheduler-event .resize-handle.bottom {
|
|
1634
|
+
bottom: 0;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/* Now indicator */
|
|
1638
|
+
.scheduler-now-indicator {
|
|
1639
|
+
position: absolute;
|
|
1640
|
+
left: 0;
|
|
1641
|
+
right: 0;
|
|
1642
|
+
height: 2px;
|
|
1643
|
+
background: var(--scheduler-now-indicator-color);
|
|
1644
|
+
z-index: 5;
|
|
1645
|
+
pointer-events: none;
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
.scheduler-now-indicator::before {
|
|
1649
|
+
content: "";
|
|
1650
|
+
position: absolute;
|
|
1651
|
+
left: -4px;
|
|
1652
|
+
top: -4px;
|
|
1653
|
+
width: 10px;
|
|
1654
|
+
height: 10px;
|
|
1655
|
+
border-radius: 50%;
|
|
1656
|
+
background: var(--scheduler-now-indicator-color);
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
/* Month View */
|
|
1660
|
+
.scheduler-month-grid {
|
|
1661
|
+
display: grid;
|
|
1662
|
+
grid-template-columns: repeat(7, 1fr);
|
|
1663
|
+
grid-auto-rows: minmax(100px, 1fr);
|
|
1664
|
+
height: 100%;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
.scheduler-month-day {
|
|
1668
|
+
border-right: 1px solid var(--scheduler-border-color);
|
|
1669
|
+
border-bottom: 1px solid var(--scheduler-border-color);
|
|
1670
|
+
padding: 4px;
|
|
1671
|
+
overflow: hidden;
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
.scheduler-month-day:nth-child(7n) {
|
|
1675
|
+
border-right: none;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
.scheduler-month-day.other-month {
|
|
1679
|
+
background: #f8f9fa;
|
|
1680
|
+
color: #adb5bd;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
.scheduler-month-day.today {
|
|
1684
|
+
background: var(--scheduler-today-bg);
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
.scheduler-month-day .day-number {
|
|
1688
|
+
font-weight: 500;
|
|
1689
|
+
margin-bottom: 4px;
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
.scheduler-month-day .month-events {
|
|
1693
|
+
display: flex;
|
|
1694
|
+
flex-direction: column;
|
|
1695
|
+
gap: 2px;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
.scheduler-month-event {
|
|
1699
|
+
padding: 2px 4px;
|
|
1700
|
+
border-radius: 2px;
|
|
1701
|
+
font-size: 11px;
|
|
1702
|
+
white-space: nowrap;
|
|
1703
|
+
overflow: hidden;
|
|
1704
|
+
text-overflow: ellipsis;
|
|
1705
|
+
cursor: pointer;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
.scheduler-more-link {
|
|
1709
|
+
font-size: 11px;
|
|
1710
|
+
color: #0d6efd;
|
|
1711
|
+
cursor: pointer;
|
|
1712
|
+
margin-top: 2px;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/* Year View */
|
|
1716
|
+
.scheduler-year-grid {
|
|
1717
|
+
display: grid;
|
|
1718
|
+
grid-template-columns: repeat(4, 1fr);
|
|
1719
|
+
gap: 16px;
|
|
1720
|
+
padding: 16px;
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
.scheduler-year-month {
|
|
1724
|
+
border: 1px solid var(--scheduler-border-color);
|
|
1725
|
+
border-radius: 4px;
|
|
1726
|
+
overflow: hidden;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
.scheduler-year-month-header {
|
|
1730
|
+
padding: 8px;
|
|
1731
|
+
background: var(--scheduler-header-bg);
|
|
1732
|
+
font-weight: 600;
|
|
1733
|
+
text-align: center;
|
|
1734
|
+
cursor: pointer;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
.scheduler-year-month-header:hover {
|
|
1738
|
+
background: #e9ecef;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
.scheduler-mini-month {
|
|
1742
|
+
display: grid;
|
|
1743
|
+
grid-template-columns: repeat(7, 1fr);
|
|
1744
|
+
gap: 1px;
|
|
1745
|
+
padding: 4px;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
.scheduler-mini-day {
|
|
1749
|
+
aspect-ratio: 1;
|
|
1750
|
+
display: flex;
|
|
1751
|
+
align-items: center;
|
|
1752
|
+
justify-content: center;
|
|
1753
|
+
font-size: 11px;
|
|
1754
|
+
cursor: pointer;
|
|
1755
|
+
border-radius: 50%;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
.scheduler-mini-day:hover {
|
|
1759
|
+
background: #e9ecef;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
.scheduler-mini-day.has-events {
|
|
1763
|
+
font-weight: 600;
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
.scheduler-mini-day.has-events::after {
|
|
1767
|
+
content: "";
|
|
1768
|
+
position: absolute;
|
|
1769
|
+
bottom: 2px;
|
|
1770
|
+
width: 4px;
|
|
1771
|
+
height: 4px;
|
|
1772
|
+
border-radius: 50%;
|
|
1773
|
+
background: #0d6efd;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
.scheduler-mini-day.today {
|
|
1777
|
+
background: var(--scheduler-today-bg);
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
.scheduler-mini-day.other-month {
|
|
1781
|
+
color: #adb5bd;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/* Timeline View */
|
|
1785
|
+
.scheduler-timeline {
|
|
1786
|
+
display: flex;
|
|
1787
|
+
flex-direction: column;
|
|
1788
|
+
height: 100%;
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
.scheduler-timeline-header {
|
|
1792
|
+
display: flex;
|
|
1793
|
+
border-bottom: 1px solid var(--scheduler-border-color);
|
|
1794
|
+
background: var(--scheduler-header-bg);
|
|
1795
|
+
position: sticky;
|
|
1796
|
+
top: 0;
|
|
1797
|
+
z-index: 10;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
.scheduler-resource-header {
|
|
1801
|
+
width: 200px;
|
|
1802
|
+
flex-shrink: 0;
|
|
1803
|
+
padding: 8px;
|
|
1804
|
+
border-right: 1px solid var(--scheduler-border-color);
|
|
1805
|
+
font-weight: 600;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
.scheduler-timeline-slots-header {
|
|
1809
|
+
display: flex;
|
|
1810
|
+
flex: 1;
|
|
1811
|
+
overflow: hidden;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
.scheduler-timeline-slot-header {
|
|
1815
|
+
flex-shrink: 0;
|
|
1816
|
+
padding: 8px;
|
|
1817
|
+
text-align: center;
|
|
1818
|
+
border-right: 1px solid var(--scheduler-border-color);
|
|
1819
|
+
font-size: 12px;
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
.scheduler-timeline-body {
|
|
1823
|
+
display: flex;
|
|
1824
|
+
flex-direction: column;
|
|
1825
|
+
flex: 1;
|
|
1826
|
+
overflow: auto;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
.scheduler-timeline-row {
|
|
1830
|
+
display: flex;
|
|
1831
|
+
border-bottom: 1px solid var(--scheduler-border-color);
|
|
1832
|
+
min-height: 40px;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
.scheduler-timeline-row.group {
|
|
1836
|
+
background: var(--scheduler-header-bg);
|
|
1837
|
+
font-weight: 600;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
.scheduler-resource-cell {
|
|
1841
|
+
width: 200px;
|
|
1842
|
+
flex-shrink: 0;
|
|
1843
|
+
padding: 8px;
|
|
1844
|
+
border-right: 1px solid var(--scheduler-border-color);
|
|
1845
|
+
display: flex;
|
|
1846
|
+
align-items: center;
|
|
1847
|
+
gap: 8px;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
.scheduler-resource-cell .expand-toggle {
|
|
1851
|
+
cursor: pointer;
|
|
1852
|
+
width: 16px;
|
|
1853
|
+
height: 16px;
|
|
1854
|
+
display: flex;
|
|
1855
|
+
align-items: center;
|
|
1856
|
+
justify-content: center;
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
.scheduler-timeline-slots {
|
|
1860
|
+
display: flex;
|
|
1861
|
+
flex: 1;
|
|
1862
|
+
position: relative;
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
.scheduler-timeline-slot {
|
|
1866
|
+
flex-shrink: 0;
|
|
1867
|
+
border-right: 1px solid #eee;
|
|
1868
|
+
height: 100%;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
.scheduler-timeline-slot.greyed {
|
|
1872
|
+
background: var(--scheduler-greyed-slot-bg);
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
.scheduler-timeline-events {
|
|
1876
|
+
position: absolute;
|
|
1877
|
+
top: 0;
|
|
1878
|
+
left: 0;
|
|
1879
|
+
right: 0;
|
|
1880
|
+
bottom: 0;
|
|
1881
|
+
pointer-events: none;
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
.scheduler-timeline-event {
|
|
1885
|
+
position: absolute;
|
|
1886
|
+
height: calc(100% - 4px);
|
|
1887
|
+
top: 2px;
|
|
1888
|
+
border-radius: var(--scheduler-event-border-radius);
|
|
1889
|
+
padding: 2px 6px;
|
|
1890
|
+
font-size: 12px;
|
|
1891
|
+
overflow: hidden;
|
|
1892
|
+
cursor: pointer;
|
|
1893
|
+
pointer-events: auto;
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
.scheduler-timeline-event.selected {
|
|
1897
|
+
box-shadow: 0 0 0 3px #212529;
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
/* Loading state */
|
|
1901
|
+
.scheduler-loading {
|
|
1902
|
+
display: flex;
|
|
1903
|
+
align-items: center;
|
|
1904
|
+
justify-content: center;
|
|
1905
|
+
height: 200px;
|
|
1906
|
+
color: #666;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
/* Empty state */
|
|
1910
|
+
.scheduler-empty {
|
|
1911
|
+
display: flex;
|
|
1912
|
+
align-items: center;
|
|
1913
|
+
justify-content: center;
|
|
1914
|
+
height: 200px;
|
|
1915
|
+
color: #666;
|
|
1916
|
+
font-style: italic;
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
/* Touch drag mode indicator */
|
|
1920
|
+
.scheduler-container.touch-drag-mode {
|
|
1921
|
+
cursor: grabbing;
|
|
1922
|
+
user-select: none;
|
|
1923
|
+
-webkit-user-select: none;
|
|
1924
|
+
touch-action: none;
|
|
1925
|
+
-ms-touch-action: none;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
.scheduler-container.touch-drag-mode .scheduler-content {
|
|
1929
|
+
overflow: hidden;
|
|
1930
|
+
touch-action: none;
|
|
1931
|
+
-ms-touch-action: none;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
.scheduler-event.touch-hold-pending {
|
|
1935
|
+
animation: touch-hold-pulse 0.5s ease-in-out;
|
|
1936
|
+
touch-action: none;
|
|
1937
|
+
-ms-touch-action: none;
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
.scheduler-event.touch-hold-active {
|
|
1941
|
+
transform: scale(1.02);
|
|
1942
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
|
1943
|
+
z-index: 100;
|
|
1944
|
+
touch-action: none;
|
|
1945
|
+
-ms-touch-action: none;
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
.scheduler-time-slot.touch-hold-pending,
|
|
1949
|
+
.scheduler-timeline-slot.touch-hold-pending {
|
|
1950
|
+
animation: touch-hold-pulse 0.5s ease-in-out;
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
.scheduler-time-slot.touch-hold-active,
|
|
1954
|
+
.scheduler-timeline-slot.touch-hold-active {
|
|
1955
|
+
background: var(--scheduler-preview-bg);
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
@keyframes touch-hold-pulse {
|
|
1959
|
+
0% {
|
|
1960
|
+
opacity: 1;
|
|
1961
|
+
}
|
|
1962
|
+
50% {
|
|
1963
|
+
opacity: 0.7;
|
|
1964
|
+
}
|
|
1965
|
+
100% {
|
|
1966
|
+
opacity: 1;
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
/* Pan mode styling */
|
|
1970
|
+
.scheduler-content.pan-mode {
|
|
1971
|
+
cursor: grabbing;
|
|
1972
|
+
user-select: none;
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
/* Scroll blocking during drag operations */
|
|
1976
|
+
.scheduler-content.scroll-blocked {
|
|
1977
|
+
overflow: hidden !important;
|
|
1978
|
+
touch-action: none !important;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
/* Scrollbar styling */
|
|
1982
|
+
.scheduler-content::-webkit-scrollbar {
|
|
1983
|
+
width: 8px;
|
|
1984
|
+
height: 8px;
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
.scheduler-content::-webkit-scrollbar-track {
|
|
1988
|
+
background: #f1f1f1;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
.scheduler-content::-webkit-scrollbar-thumb {
|
|
1992
|
+
background: #c1c1c1;
|
|
1993
|
+
border-radius: 4px;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
.scheduler-content::-webkit-scrollbar-thumb:hover {
|
|
1997
|
+
background: #a1a1a1;
|
|
1998
|
+
}`);
|
|
1999
|
+
|
|
2000
|
+
/**
|
|
2001
|
+
* Default drag configuration.
|
|
2002
|
+
*/
|
|
2003
|
+
const DEFAULT_DRAG_CONFIG = {
|
|
2004
|
+
dragThreshold: 5,
|
|
2005
|
+
minDurationMs: 30 * 60 * 1000, // 30 minutes
|
|
2006
|
+
};
|
|
2007
|
+
|
|
2008
|
+
/**
|
|
2009
|
+
* Calculates preview positions for drag operations.
|
|
2010
|
+
* Handles create, move, and resize operations.
|
|
2011
|
+
*/
|
|
2012
|
+
class DragPreviewCalculator {
|
|
2013
|
+
constructor(config = {}) {
|
|
2014
|
+
this.config = { ...DEFAULT_DRAG_CONFIG, ...config };
|
|
2015
|
+
}
|
|
2016
|
+
/**
|
|
2017
|
+
* Calculate the preview event position based on drag state.
|
|
2018
|
+
*
|
|
2019
|
+
* @param type - The type of drag operation
|
|
2020
|
+
* @param startSlot - The slot where the drag started
|
|
2021
|
+
* @param currentSlot - The current slot under the pointer
|
|
2022
|
+
* @param originalEvent - The original event being moved/resized (null for create)
|
|
2023
|
+
* @returns The preview event with new start/end, or null if invalid
|
|
2024
|
+
*/
|
|
2025
|
+
calculatePreview(type, startSlot, currentSlot, originalEvent) {
|
|
2026
|
+
switch (type) {
|
|
2027
|
+
case 'create':
|
|
2028
|
+
return this.calculateCreatePreview(startSlot, currentSlot);
|
|
2029
|
+
case 'move':
|
|
2030
|
+
return originalEvent
|
|
2031
|
+
? this.calculateMovePreview(startSlot, currentSlot, originalEvent)
|
|
2032
|
+
: null;
|
|
2033
|
+
case 'resize-start':
|
|
2034
|
+
return originalEvent
|
|
2035
|
+
? this.calculateResizeStartPreview(currentSlot, originalEvent)
|
|
2036
|
+
: null;
|
|
2037
|
+
case 'resize-end':
|
|
2038
|
+
return originalEvent
|
|
2039
|
+
? this.calculateResizeEndPreview(currentSlot, originalEvent)
|
|
2040
|
+
: null;
|
|
2041
|
+
default:
|
|
2042
|
+
return null;
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
2046
|
+
* Calculate preview for creating a new event.
|
|
2047
|
+
* Extends selection from start slot to current slot.
|
|
2048
|
+
*/
|
|
2049
|
+
calculateCreatePreview(startSlot, currentSlot) {
|
|
2050
|
+
const start = new Date(Math.min(startSlot.start.getTime(), currentSlot.start.getTime()));
|
|
2051
|
+
const end = new Date(Math.max(startSlot.end.getTime(), currentSlot.end.getTime()));
|
|
2052
|
+
return { start, end };
|
|
2053
|
+
}
|
|
2054
|
+
/**
|
|
2055
|
+
* Calculate preview for moving an existing event.
|
|
2056
|
+
* Preserves event duration, applies offset from drag start.
|
|
2057
|
+
*/
|
|
2058
|
+
calculateMovePreview(startSlot, currentSlot, originalEvent) {
|
|
2059
|
+
// Calculate how much the pointer has moved in time
|
|
2060
|
+
const offsetMs = currentSlot.start.getTime() - startSlot.start.getTime();
|
|
2061
|
+
// Preserve the original event duration
|
|
2062
|
+
const duration = originalEvent.end.getTime() - originalEvent.start.getTime();
|
|
2063
|
+
// Apply offset to original event position
|
|
2064
|
+
const newStart = new Date(originalEvent.start.getTime() + offsetMs);
|
|
2065
|
+
const newEnd = new Date(newStart.getTime() + duration);
|
|
2066
|
+
return { start: newStart, end: newEnd };
|
|
2067
|
+
}
|
|
2068
|
+
/**
|
|
2069
|
+
* Calculate preview for resizing event start.
|
|
2070
|
+
* Moves start time while keeping end fixed.
|
|
2071
|
+
* Enforces minimum duration.
|
|
2072
|
+
*/
|
|
2073
|
+
calculateResizeStartPreview(currentSlot, originalEvent) {
|
|
2074
|
+
// Calculate maximum allowed start time (end - min duration)
|
|
2075
|
+
const maxStart = originalEvent.end.getTime() - this.config.minDurationMs;
|
|
2076
|
+
// New start is the earlier of: current slot start, or max allowed start
|
|
2077
|
+
const newStart = new Date(Math.min(currentSlot.start.getTime(), maxStart));
|
|
2078
|
+
return { start: newStart, end: originalEvent.end };
|
|
2079
|
+
}
|
|
2080
|
+
/**
|
|
2081
|
+
* Calculate preview for resizing event end.
|
|
2082
|
+
* Moves end time while keeping start fixed.
|
|
2083
|
+
* Enforces minimum duration.
|
|
2084
|
+
*/
|
|
2085
|
+
calculateResizeEndPreview(currentSlot, originalEvent) {
|
|
2086
|
+
// Calculate minimum allowed end time (start + min duration)
|
|
2087
|
+
const minEnd = originalEvent.start.getTime() + this.config.minDurationMs;
|
|
2088
|
+
// New end is the later of: current slot end, or min allowed end
|
|
2089
|
+
const newEnd = new Date(Math.max(currentSlot.end.getTime(), minEnd));
|
|
2090
|
+
return { start: originalEvent.start, end: newEnd };
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
/**
|
|
2095
|
+
* Create a normalized pointer event from a mouse event.
|
|
2096
|
+
*/
|
|
2097
|
+
function normalizeMouseEvent(event) {
|
|
2098
|
+
return {
|
|
2099
|
+
pointerId: 0,
|
|
2100
|
+
pointerType: 'mouse',
|
|
2101
|
+
clientX: event.clientX,
|
|
2102
|
+
clientY: event.clientY,
|
|
2103
|
+
originalEvent: event,
|
|
2104
|
+
target: event.target,
|
|
2105
|
+
isPrimary: true,
|
|
2106
|
+
};
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Create a normalized pointer event from a touch event.
|
|
2110
|
+
* Returns the first touch, or null if no touches.
|
|
2111
|
+
*/
|
|
2112
|
+
function normalizeTouchEvent(event) {
|
|
2113
|
+
const touch = event.touches[0] || event.changedTouches[0];
|
|
2114
|
+
if (!touch)
|
|
2115
|
+
return null;
|
|
2116
|
+
return {
|
|
2117
|
+
pointerId: touch.identifier,
|
|
2118
|
+
pointerType: 'touch',
|
|
2119
|
+
clientX: touch.clientX,
|
|
2120
|
+
clientY: touch.clientY,
|
|
2121
|
+
originalEvent: event,
|
|
2122
|
+
target: touch.target,
|
|
2123
|
+
isPrimary: touch.identifier === 0,
|
|
2124
|
+
};
|
|
2125
|
+
}
|
|
2126
|
+
/**
|
|
2127
|
+
* Calculate distance between two positions.
|
|
2128
|
+
*/
|
|
2129
|
+
function getPointerDistance(pos1, pos2) {
|
|
2130
|
+
const dx = pos2.x - pos1.x;
|
|
2131
|
+
const dy = pos2.y - pos1.y;
|
|
2132
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
/**
|
|
2136
|
+
* Explicit state machine for drag operations.
|
|
2137
|
+
*
|
|
2138
|
+
* States:
|
|
2139
|
+
* - idle: No drag operation in progress
|
|
2140
|
+
* - pending: Pointer is down, waiting for movement to exceed threshold
|
|
2141
|
+
* - active: Drag is in progress, preview is being updated
|
|
2142
|
+
* - completing: Drag just finished, result is available
|
|
2143
|
+
*
|
|
2144
|
+
* Transitions:
|
|
2145
|
+
* - idle + POINTER_DOWN (on valid target) → pending
|
|
2146
|
+
* - pending + POINTER_MOVE (threshold exceeded) → active
|
|
2147
|
+
* - pending + POINTER_UP → idle (treated as click)
|
|
2148
|
+
* - pending + CANCEL → idle
|
|
2149
|
+
* - active + POINTER_MOVE → active (update preview)
|
|
2150
|
+
* - active + POINTER_UP → completing
|
|
2151
|
+
* - active + CANCEL → idle
|
|
2152
|
+
* - completing → idle (after result is consumed)
|
|
2153
|
+
*/
|
|
2154
|
+
class DragStateMachine {
|
|
2155
|
+
constructor(config = {}) {
|
|
2156
|
+
this.state = { phase: 'idle' };
|
|
2157
|
+
this.config = { ...DEFAULT_DRAG_CONFIG, ...config };
|
|
2158
|
+
this.previewCalculator = new DragPreviewCalculator(this.config);
|
|
2159
|
+
}
|
|
2160
|
+
/**
|
|
2161
|
+
* Get current state (read-only).
|
|
2162
|
+
*/
|
|
2163
|
+
getState() {
|
|
2164
|
+
return this.state;
|
|
2165
|
+
}
|
|
2166
|
+
/**
|
|
2167
|
+
* Get the current phase.
|
|
2168
|
+
*/
|
|
2169
|
+
getPhase() {
|
|
2170
|
+
return this.state.phase;
|
|
2171
|
+
}
|
|
2172
|
+
/**
|
|
2173
|
+
* Check if drag is currently active.
|
|
2174
|
+
*/
|
|
2175
|
+
isActive() {
|
|
2176
|
+
return this.state.phase === 'active';
|
|
2177
|
+
}
|
|
2178
|
+
/**
|
|
2179
|
+
* Check if drag is pending (waiting for threshold).
|
|
2180
|
+
*/
|
|
2181
|
+
isPending() {
|
|
2182
|
+
return this.state.phase === 'pending';
|
|
2183
|
+
}
|
|
2184
|
+
/**
|
|
2185
|
+
* Check if in any drag-related state (pending or active).
|
|
2186
|
+
*/
|
|
2187
|
+
isDragging() {
|
|
2188
|
+
return this.state.phase === 'pending' || this.state.phase === 'active';
|
|
2189
|
+
}
|
|
2190
|
+
/**
|
|
2191
|
+
* Get the preview event if in active state.
|
|
2192
|
+
*/
|
|
2193
|
+
getPreview() {
|
|
2194
|
+
return this.state.phase === 'active' ? this.state.preview : null;
|
|
2195
|
+
}
|
|
2196
|
+
/**
|
|
2197
|
+
* Get the completion result if in completing state.
|
|
2198
|
+
*/
|
|
2199
|
+
getCompletionResult() {
|
|
2200
|
+
return this.state.phase === 'completing' ? this.state.result : null;
|
|
2201
|
+
}
|
|
2202
|
+
/**
|
|
2203
|
+
* Process an event and transition to new state.
|
|
2204
|
+
* Returns true if state changed.
|
|
2205
|
+
*/
|
|
2206
|
+
send(event) {
|
|
2207
|
+
const previousPhase = this.state.phase;
|
|
2208
|
+
this.state = this.transition(event);
|
|
2209
|
+
return this.state.phase !== previousPhase;
|
|
2210
|
+
}
|
|
2211
|
+
/**
|
|
2212
|
+
* Reset to idle state.
|
|
2213
|
+
*/
|
|
2214
|
+
reset() {
|
|
2215
|
+
this.state = { phase: 'idle' };
|
|
2216
|
+
}
|
|
2217
|
+
/**
|
|
2218
|
+
* Consume the completion result and return to idle.
|
|
2219
|
+
* Call this after handling a completed drag.
|
|
2220
|
+
*/
|
|
2221
|
+
consumeResult() {
|
|
2222
|
+
if (this.state.phase === 'completing') {
|
|
2223
|
+
const result = this.state.result;
|
|
2224
|
+
this.state = { phase: 'idle' };
|
|
2225
|
+
return result;
|
|
2226
|
+
}
|
|
2227
|
+
return null;
|
|
2228
|
+
}
|
|
2229
|
+
/**
|
|
2230
|
+
* Pure transition function.
|
|
2231
|
+
* Given current state and event, returns new state.
|
|
2232
|
+
*/
|
|
2233
|
+
transition(event) {
|
|
2234
|
+
switch (this.state.phase) {
|
|
2235
|
+
case 'idle':
|
|
2236
|
+
return this.transitionFromIdle(event);
|
|
2237
|
+
case 'pending':
|
|
2238
|
+
return this.transitionFromPending(event);
|
|
2239
|
+
case 'active':
|
|
2240
|
+
return this.transitionFromActive(event);
|
|
2241
|
+
case 'completing':
|
|
2242
|
+
return this.transitionFromCompleting(event);
|
|
2243
|
+
default:
|
|
2244
|
+
return this.state;
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
/**
|
|
2248
|
+
* Transitions from idle state.
|
|
2249
|
+
*/
|
|
2250
|
+
transitionFromIdle(event) {
|
|
2251
|
+
if (event.type !== 'POINTER_DOWN') {
|
|
2252
|
+
return this.state;
|
|
2253
|
+
}
|
|
2254
|
+
const { target, position, slot, slotElement, immediate } = event;
|
|
2255
|
+
// Determine operation type from target
|
|
2256
|
+
const operationType = this.getOperationType(target);
|
|
2257
|
+
if (!operationType) {
|
|
2258
|
+
return this.state;
|
|
2259
|
+
}
|
|
2260
|
+
// Get the event being dragged (if any)
|
|
2261
|
+
const schedulerEvent = target.event ?? null;
|
|
2262
|
+
// Check if this event is draggable
|
|
2263
|
+
if (schedulerEvent && schedulerEvent.draggable === false) {
|
|
2264
|
+
return this.state;
|
|
2265
|
+
}
|
|
2266
|
+
// For touch-initiated drags, skip pending and go directly to active
|
|
2267
|
+
if (immediate) {
|
|
2268
|
+
// For move operations without a slot, create one from the event's times
|
|
2269
|
+
let startSlot = slot;
|
|
2270
|
+
if (!startSlot && schedulerEvent && operationType === 'move') {
|
|
2271
|
+
startSlot = {
|
|
2272
|
+
start: schedulerEvent.start,
|
|
2273
|
+
end: schedulerEvent.end,
|
|
2274
|
+
};
|
|
2275
|
+
}
|
|
2276
|
+
if (startSlot) {
|
|
2277
|
+
const preview = this.previewCalculator.calculatePreview(operationType, startSlot, startSlot, schedulerEvent);
|
|
2278
|
+
if (preview) {
|
|
2279
|
+
return {
|
|
2280
|
+
phase: 'active',
|
|
2281
|
+
operationType,
|
|
2282
|
+
event: schedulerEvent,
|
|
2283
|
+
startSlot,
|
|
2284
|
+
currentSlot: startSlot,
|
|
2285
|
+
preview,
|
|
2286
|
+
originalEvent: schedulerEvent ?? undefined,
|
|
2287
|
+
};
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
return {
|
|
2292
|
+
phase: 'pending',
|
|
2293
|
+
operationType,
|
|
2294
|
+
event: schedulerEvent,
|
|
2295
|
+
startPosition: position,
|
|
2296
|
+
startSlot: slot,
|
|
2297
|
+
slotElement,
|
|
2298
|
+
};
|
|
2299
|
+
}
|
|
2300
|
+
/**
|
|
2301
|
+
* Transitions from pending state.
|
|
2302
|
+
*/
|
|
2303
|
+
transitionFromPending(event) {
|
|
2304
|
+
if (this.state.phase !== 'pending')
|
|
2305
|
+
return this.state;
|
|
2306
|
+
switch (event.type) {
|
|
2307
|
+
case 'POINTER_MOVE': {
|
|
2308
|
+
const distance = getPointerDistance(this.state.startPosition, event.position);
|
|
2309
|
+
if (distance < this.config.dragThreshold) {
|
|
2310
|
+
// Not enough movement, stay in pending
|
|
2311
|
+
return this.state;
|
|
2312
|
+
}
|
|
2313
|
+
// Threshold exceeded - activate drag
|
|
2314
|
+
return this.activateDrag(event.slot);
|
|
2315
|
+
}
|
|
2316
|
+
case 'POINTER_UP': {
|
|
2317
|
+
// Released before threshold - treat as click
|
|
2318
|
+
return {
|
|
2319
|
+
phase: 'completing',
|
|
2320
|
+
result: {
|
|
2321
|
+
type: this.state.operationType,
|
|
2322
|
+
preview: this.getInitialPreview(),
|
|
2323
|
+
event: this.state.event,
|
|
2324
|
+
originalEvent: this.state.event ?? undefined,
|
|
2325
|
+
wasClick: true,
|
|
2326
|
+
},
|
|
2327
|
+
};
|
|
2328
|
+
}
|
|
2329
|
+
case 'CANCEL':
|
|
2330
|
+
return { phase: 'idle' };
|
|
2331
|
+
default:
|
|
2332
|
+
return this.state;
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
/**
|
|
2336
|
+
* Transitions from active state.
|
|
2337
|
+
*/
|
|
2338
|
+
transitionFromActive(event) {
|
|
2339
|
+
if (this.state.phase !== 'active')
|
|
2340
|
+
return this.state;
|
|
2341
|
+
switch (event.type) {
|
|
2342
|
+
case 'POINTER_MOVE': {
|
|
2343
|
+
if (!event.slot) {
|
|
2344
|
+
// No valid slot under pointer, keep current state
|
|
2345
|
+
return this.state;
|
|
2346
|
+
}
|
|
2347
|
+
// Calculate new preview
|
|
2348
|
+
const preview = this.previewCalculator.calculatePreview(this.state.operationType, this.state.startSlot, event.slot, this.state.originalEvent ?? null);
|
|
2349
|
+
if (!preview) {
|
|
2350
|
+
return this.state;
|
|
2351
|
+
}
|
|
2352
|
+
return {
|
|
2353
|
+
...this.state,
|
|
2354
|
+
currentSlot: event.slot,
|
|
2355
|
+
preview,
|
|
2356
|
+
};
|
|
2357
|
+
}
|
|
2358
|
+
case 'POINTER_UP': {
|
|
2359
|
+
return {
|
|
2360
|
+
phase: 'completing',
|
|
2361
|
+
result: {
|
|
2362
|
+
type: this.state.operationType,
|
|
2363
|
+
preview: this.state.preview,
|
|
2364
|
+
event: this.state.event,
|
|
2365
|
+
originalEvent: this.state.originalEvent,
|
|
2366
|
+
wasClick: false,
|
|
2367
|
+
},
|
|
2368
|
+
};
|
|
2369
|
+
}
|
|
2370
|
+
case 'CANCEL':
|
|
2371
|
+
return { phase: 'idle' };
|
|
2372
|
+
default:
|
|
2373
|
+
return this.state;
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
/**
|
|
2377
|
+
* Transitions from completing state.
|
|
2378
|
+
*/
|
|
2379
|
+
transitionFromCompleting(event) {
|
|
2380
|
+
// Any event from completing goes to idle
|
|
2381
|
+
// (The result should be consumed via consumeResult first)
|
|
2382
|
+
if (event.type === 'CANCEL' || event.type === 'POINTER_DOWN') {
|
|
2383
|
+
return { phase: 'idle' };
|
|
2384
|
+
}
|
|
2385
|
+
return this.state;
|
|
2386
|
+
}
|
|
2387
|
+
/**
|
|
2388
|
+
* Activate drag from pending state.
|
|
2389
|
+
*/
|
|
2390
|
+
activateDrag(currentSlot) {
|
|
2391
|
+
if (this.state.phase !== 'pending') {
|
|
2392
|
+
return this.state;
|
|
2393
|
+
}
|
|
2394
|
+
const startSlot = this.state.startSlot ?? currentSlot;
|
|
2395
|
+
if (!startSlot) {
|
|
2396
|
+
return { phase: 'idle' };
|
|
2397
|
+
}
|
|
2398
|
+
const slot = currentSlot ?? startSlot;
|
|
2399
|
+
// Calculate initial preview
|
|
2400
|
+
const preview = this.previewCalculator.calculatePreview(this.state.operationType, startSlot, slot, this.state.event);
|
|
2401
|
+
if (!preview) {
|
|
2402
|
+
return { phase: 'idle' };
|
|
2403
|
+
}
|
|
2404
|
+
return {
|
|
2405
|
+
phase: 'active',
|
|
2406
|
+
operationType: this.state.operationType,
|
|
2407
|
+
event: this.state.event,
|
|
2408
|
+
startSlot,
|
|
2409
|
+
currentSlot: slot,
|
|
2410
|
+
preview,
|
|
2411
|
+
originalEvent: this.state.event ?? undefined,
|
|
2412
|
+
};
|
|
2413
|
+
}
|
|
2414
|
+
/**
|
|
2415
|
+
* Get operation type from pointer target.
|
|
2416
|
+
*/
|
|
2417
|
+
getOperationType(target) {
|
|
2418
|
+
switch (target.type) {
|
|
2419
|
+
case 'resize-handle':
|
|
2420
|
+
return target.resizeHandle === 'start' ? 'resize-start' : 'resize-end';
|
|
2421
|
+
case 'event':
|
|
2422
|
+
return 'move';
|
|
2423
|
+
case 'slot':
|
|
2424
|
+
return 'create';
|
|
2425
|
+
default:
|
|
2426
|
+
return null;
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
/**
|
|
2430
|
+
* Get initial preview for a pending drag (used for click detection).
|
|
2431
|
+
*/
|
|
2432
|
+
getInitialPreview() {
|
|
2433
|
+
if (this.state.phase !== 'pending') {
|
|
2434
|
+
return { start: new Date(), end: new Date() };
|
|
2435
|
+
}
|
|
2436
|
+
if (this.state.event) {
|
|
2437
|
+
return {
|
|
2438
|
+
start: this.state.event.start,
|
|
2439
|
+
end: this.state.event.end,
|
|
2440
|
+
};
|
|
2441
|
+
}
|
|
2442
|
+
if (this.state.startSlot) {
|
|
2443
|
+
return {
|
|
2444
|
+
start: this.state.startSlot.start,
|
|
2445
|
+
end: this.state.startSlot.end,
|
|
2446
|
+
};
|
|
2447
|
+
}
|
|
2448
|
+
return { start: new Date(), end: new Date() };
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
/**
|
|
2453
|
+
* Coordinates drag operations between the state machine and scheduler state.
|
|
2454
|
+
* Handles RAF scheduling for smooth drag updates.
|
|
2455
|
+
*/
|
|
2456
|
+
class DragManager {
|
|
2457
|
+
constructor(stateManager, config = {}) {
|
|
2458
|
+
// RAF scheduling for smooth updates
|
|
2459
|
+
this.pendingUpdate = null;
|
|
2460
|
+
this.latestPointer = null;
|
|
2461
|
+
this.getSlotAtPosition = null;
|
|
2462
|
+
this.stateManager = stateManager;
|
|
2463
|
+
this.config = { ...DEFAULT_DRAG_CONFIG, ...config };
|
|
2464
|
+
this.stateMachine = new DragStateMachine(this.config);
|
|
2465
|
+
}
|
|
2466
|
+
/**
|
|
2467
|
+
* Set the function used to get a slot at a position.
|
|
2468
|
+
* This must be set before handling pointer events.
|
|
2469
|
+
*/
|
|
2470
|
+
setSlotResolver(resolver) {
|
|
2471
|
+
this.getSlotAtPosition = resolver;
|
|
2472
|
+
}
|
|
2473
|
+
/**
|
|
2474
|
+
* Handle pointer down - may start pending drag.
|
|
2475
|
+
* @param immediate If true, skip pending state (for touch-initiated drags)
|
|
2476
|
+
*/
|
|
2477
|
+
handlePointerDown(pointer, target, immediate = false) {
|
|
2478
|
+
const slot = this.getSlotAtPosition?.(pointer.clientX, pointer.clientY) ?? null;
|
|
2479
|
+
this.stateMachine.send({
|
|
2480
|
+
type: 'POINTER_DOWN',
|
|
2481
|
+
target,
|
|
2482
|
+
position: { x: pointer.clientX, y: pointer.clientY },
|
|
2483
|
+
slot,
|
|
2484
|
+
slotElement: target.slotElement,
|
|
2485
|
+
immediate,
|
|
2486
|
+
});
|
|
2487
|
+
// If we started a drag, update state manager
|
|
2488
|
+
if (this.stateMachine.isActive()) {
|
|
2489
|
+
this.syncToStateManager();
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
/**
|
|
2493
|
+
* Handle pointer move - may activate drag or update preview.
|
|
2494
|
+
*/
|
|
2495
|
+
handlePointerMove(pointer) {
|
|
2496
|
+
if (!this.stateMachine.isDragging()) {
|
|
2497
|
+
return;
|
|
2498
|
+
}
|
|
2499
|
+
// Store latest pointer for RAF callback
|
|
2500
|
+
this.latestPointer = pointer;
|
|
2501
|
+
// Schedule update if not already pending
|
|
2502
|
+
if (this.pendingUpdate === null) {
|
|
2503
|
+
this.pendingUpdate = requestAnimationFrame(() => {
|
|
2504
|
+
this.processPendingMove();
|
|
2505
|
+
});
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
/**
|
|
2509
|
+
* Handle pointer up - finalize or cancel drag.
|
|
2510
|
+
* Returns the completion result if a drag was completed.
|
|
2511
|
+
*/
|
|
2512
|
+
handlePointerUp(pointer) {
|
|
2513
|
+
// Cancel any pending RAF
|
|
2514
|
+
this.cancelPendingUpdate();
|
|
2515
|
+
this.stateMachine.send({
|
|
2516
|
+
type: 'POINTER_UP',
|
|
2517
|
+
position: { x: pointer.clientX, y: pointer.clientY },
|
|
2518
|
+
});
|
|
2519
|
+
// Get and consume the result
|
|
2520
|
+
const result = this.stateMachine.consumeResult();
|
|
2521
|
+
// Clear state manager drag state
|
|
2522
|
+
this.stateManager.endDrag();
|
|
2523
|
+
return result;
|
|
2524
|
+
}
|
|
2525
|
+
/**
|
|
2526
|
+
* Cancel any in-progress drag operation.
|
|
2527
|
+
*/
|
|
2528
|
+
cancel() {
|
|
2529
|
+
this.cancelPendingUpdate();
|
|
2530
|
+
this.stateMachine.send({ type: 'CANCEL' });
|
|
2531
|
+
this.stateManager.endDrag();
|
|
2532
|
+
}
|
|
2533
|
+
/**
|
|
2534
|
+
* Check if a drag operation is in progress.
|
|
2535
|
+
*/
|
|
2536
|
+
isActive() {
|
|
2537
|
+
return this.stateMachine.isActive();
|
|
2538
|
+
}
|
|
2539
|
+
/**
|
|
2540
|
+
* Check if waiting for drag threshold.
|
|
2541
|
+
*/
|
|
2542
|
+
isPending() {
|
|
2543
|
+
return this.stateMachine.isPending();
|
|
2544
|
+
}
|
|
2545
|
+
/**
|
|
2546
|
+
* Check if in any drag-related state.
|
|
2547
|
+
*/
|
|
2548
|
+
isDragging() {
|
|
2549
|
+
return this.stateMachine.isDragging();
|
|
2550
|
+
}
|
|
2551
|
+
/**
|
|
2552
|
+
* Get the current drag phase.
|
|
2553
|
+
*/
|
|
2554
|
+
getPhase() {
|
|
2555
|
+
return this.stateMachine.getPhase();
|
|
2556
|
+
}
|
|
2557
|
+
/**
|
|
2558
|
+
* Reset the drag manager state.
|
|
2559
|
+
*/
|
|
2560
|
+
reset() {
|
|
2561
|
+
this.cancelPendingUpdate();
|
|
2562
|
+
this.stateMachine.reset();
|
|
2563
|
+
}
|
|
2564
|
+
/**
|
|
2565
|
+
* Clean up resources.
|
|
2566
|
+
*/
|
|
2567
|
+
destroy() {
|
|
2568
|
+
this.cancelPendingUpdate();
|
|
2569
|
+
this.latestPointer = null;
|
|
2570
|
+
this.getSlotAtPosition = null;
|
|
2571
|
+
}
|
|
2572
|
+
/**
|
|
2573
|
+
* Create a new event from a completed create drag.
|
|
2574
|
+
*/
|
|
2575
|
+
createEventFromResult(result) {
|
|
2576
|
+
return {
|
|
2577
|
+
id: generateEventId(),
|
|
2578
|
+
title: 'New Event',
|
|
2579
|
+
start: result.preview.start,
|
|
2580
|
+
end: result.preview.end,
|
|
2581
|
+
color: '#3788d8',
|
|
2582
|
+
};
|
|
2583
|
+
}
|
|
2584
|
+
/**
|
|
2585
|
+
* Update an event from a completed move/resize drag.
|
|
2586
|
+
*/
|
|
2587
|
+
updateEventFromResult(result, event) {
|
|
2588
|
+
return {
|
|
2589
|
+
...event,
|
|
2590
|
+
start: result.preview.start,
|
|
2591
|
+
end: result.preview.end,
|
|
2592
|
+
};
|
|
2593
|
+
}
|
|
2594
|
+
/**
|
|
2595
|
+
* Process pending pointer move using RAF.
|
|
2596
|
+
*/
|
|
2597
|
+
processPendingMove() {
|
|
2598
|
+
this.pendingUpdate = null;
|
|
2599
|
+
const pointer = this.latestPointer;
|
|
2600
|
+
this.latestPointer = null;
|
|
2601
|
+
if (!pointer || !this.stateMachine.isDragging()) {
|
|
2602
|
+
return;
|
|
2603
|
+
}
|
|
2604
|
+
const slot = this.getSlotAtPosition?.(pointer.clientX, pointer.clientY) ?? null;
|
|
2605
|
+
const stateChanged = this.stateMachine.send({
|
|
2606
|
+
type: 'POINTER_MOVE',
|
|
2607
|
+
position: { x: pointer.clientX, y: pointer.clientY },
|
|
2608
|
+
slot,
|
|
2609
|
+
});
|
|
2610
|
+
// Sync to state manager if state changed
|
|
2611
|
+
if (stateChanged || this.stateMachine.isActive()) {
|
|
2612
|
+
this.syncToStateManager();
|
|
2613
|
+
}
|
|
2614
|
+
}
|
|
2615
|
+
/**
|
|
2616
|
+
* Sync state machine state to scheduler state manager.
|
|
2617
|
+
*/
|
|
2618
|
+
syncToStateManager() {
|
|
2619
|
+
const machineState = this.stateMachine.getState();
|
|
2620
|
+
if (machineState.phase === 'active') {
|
|
2621
|
+
// Update or start drag in state manager
|
|
2622
|
+
const currentState = this.stateManager.getState();
|
|
2623
|
+
if (currentState.dragState) {
|
|
2624
|
+
// Update existing drag
|
|
2625
|
+
this.stateManager.updateDrag(machineState.currentSlot, machineState.preview);
|
|
2626
|
+
}
|
|
2627
|
+
else {
|
|
2628
|
+
// Start new drag
|
|
2629
|
+
this.stateManager.startDrag({
|
|
2630
|
+
type: machineState.operationType,
|
|
2631
|
+
event: machineState.event,
|
|
2632
|
+
startSlot: machineState.startSlot,
|
|
2633
|
+
currentSlot: machineState.currentSlot,
|
|
2634
|
+
preview: machineState.preview,
|
|
2635
|
+
originalEvent: machineState.originalEvent,
|
|
2636
|
+
});
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
/**
|
|
2641
|
+
* Cancel any pending RAF update.
|
|
2642
|
+
*/
|
|
2643
|
+
cancelPendingUpdate() {
|
|
2644
|
+
if (this.pendingUpdate !== null) {
|
|
2645
|
+
cancelAnimationFrame(this.pendingUpdate);
|
|
2646
|
+
this.pendingUpdate = null;
|
|
2647
|
+
}
|
|
2648
|
+
this.latestPointer = null;
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
const DEFAULT_TOUCH_HOLD_DURATION = 600;
|
|
2653
|
+
const DEFAULT_TOUCH_MOVE_THRESHOLD = 10;
|
|
2654
|
+
const DEFAULT_MOUSE_PAN_TIMEOUT = 600;
|
|
2655
|
+
/**
|
|
2656
|
+
* Unified input handler for mouse and touch events.
|
|
2657
|
+
* Normalizes events and handles touch-specific behaviors (hold-to-drag).
|
|
2658
|
+
*/
|
|
2659
|
+
class InputHandler {
|
|
2660
|
+
constructor(config, callbacks) {
|
|
2661
|
+
// Touch-specific state
|
|
2662
|
+
this.touchHoldTimer = null;
|
|
2663
|
+
this.touchStartPosition = null;
|
|
2664
|
+
this.isTouchDragMode = false;
|
|
2665
|
+
this.touchHoldTarget = null;
|
|
2666
|
+
this.touchHoldPointer = null;
|
|
2667
|
+
// Pan mode state (for panning when touch/mouse starts on event and moves quickly)
|
|
2668
|
+
this.isPanMode = false;
|
|
2669
|
+
this.panStartPosition = null;
|
|
2670
|
+
this.panStartScroll = null;
|
|
2671
|
+
this.panStartedOnEvent = false;
|
|
2672
|
+
// Mouse pan state (for panning when mousedown on event and move within timeout)
|
|
2673
|
+
this.mousePanTimer = null;
|
|
2674
|
+
this.mouseStartPosition = null;
|
|
2675
|
+
this.isMousePanCandidate = false;
|
|
2676
|
+
// Scroll blocking state (saved styles for restoration)
|
|
2677
|
+
this.savedBodyStyles = null;
|
|
2678
|
+
this.config = config;
|
|
2679
|
+
this.callbacks = callbacks;
|
|
2680
|
+
// Bind handlers
|
|
2681
|
+
this.boundHandleMouseDown = this.handleMouseDown.bind(this);
|
|
2682
|
+
this.boundHandleMouseMove = this.handleMouseMove.bind(this);
|
|
2683
|
+
this.boundHandleMouseUp = this.handleMouseUp.bind(this);
|
|
2684
|
+
this.boundHandleClick = this.handleClick.bind(this);
|
|
2685
|
+
this.boundHandleDblClick = this.handleDblClick.bind(this);
|
|
2686
|
+
this.boundHandleTouchStart = this.handleTouchStart.bind(this);
|
|
2687
|
+
this.boundHandleTouchMove = this.handleTouchMove.bind(this);
|
|
2688
|
+
this.boundHandleTouchEnd = this.handleTouchEnd.bind(this);
|
|
2689
|
+
this.boundHandleTouchCancel = this.handleTouchCancel.bind(this);
|
|
2690
|
+
}
|
|
2691
|
+
/**
|
|
2692
|
+
* Attach all event listeners.
|
|
2693
|
+
*/
|
|
2694
|
+
attach() {
|
|
2695
|
+
const root = this.config.shadowRoot;
|
|
2696
|
+
root.addEventListener('mousedown', this.boundHandleMouseDown);
|
|
2697
|
+
document.addEventListener('mousemove', this.boundHandleMouseMove);
|
|
2698
|
+
document.addEventListener('mouseup', this.boundHandleMouseUp);
|
|
2699
|
+
root.addEventListener('click', this.boundHandleClick);
|
|
2700
|
+
root.addEventListener('dblclick', this.boundHandleDblClick);
|
|
2701
|
+
root.addEventListener('touchstart', this.boundHandleTouchStart, {
|
|
2702
|
+
passive: false,
|
|
2703
|
+
});
|
|
2704
|
+
root.addEventListener('touchmove', this.boundHandleTouchMove, {
|
|
2705
|
+
passive: false,
|
|
2706
|
+
});
|
|
2707
|
+
root.addEventListener('touchend', this.boundHandleTouchEnd);
|
|
2708
|
+
root.addEventListener('touchcancel', this.boundHandleTouchCancel);
|
|
2709
|
+
}
|
|
2710
|
+
/**
|
|
2711
|
+
* Detach all event listeners.
|
|
2712
|
+
*/
|
|
2713
|
+
detach() {
|
|
2714
|
+
const root = this.config.shadowRoot;
|
|
2715
|
+
root.removeEventListener('mousedown', this.boundHandleMouseDown);
|
|
2716
|
+
document.removeEventListener('mousemove', this.boundHandleMouseMove);
|
|
2717
|
+
document.removeEventListener('mouseup', this.boundHandleMouseUp);
|
|
2718
|
+
root.removeEventListener('click', this.boundHandleClick);
|
|
2719
|
+
root.removeEventListener('dblclick', this.boundHandleDblClick);
|
|
2720
|
+
root.removeEventListener('touchstart', this.boundHandleTouchStart);
|
|
2721
|
+
root.removeEventListener('touchmove', this.boundHandleTouchMove);
|
|
2722
|
+
root.removeEventListener('touchend', this.boundHandleTouchEnd);
|
|
2723
|
+
root.removeEventListener('touchcancel', this.boundHandleTouchCancel);
|
|
2724
|
+
this.cancelTouchHold();
|
|
2725
|
+
this.cleanupMousePanState();
|
|
2726
|
+
this.exitPanMode();
|
|
2727
|
+
this.removeScrollBlock();
|
|
2728
|
+
}
|
|
2729
|
+
/**
|
|
2730
|
+
* Check if in touch drag mode.
|
|
2731
|
+
*/
|
|
2732
|
+
isInTouchDragMode() {
|
|
2733
|
+
return this.isTouchDragMode;
|
|
2734
|
+
}
|
|
2735
|
+
/**
|
|
2736
|
+
* Analyze pointer target to determine what was clicked.
|
|
2737
|
+
*/
|
|
2738
|
+
analyzeTarget(element) {
|
|
2739
|
+
// Check for resize handle first
|
|
2740
|
+
const resizeHandle = element.closest('.resize-handle');
|
|
2741
|
+
if (resizeHandle) {
|
|
2742
|
+
const eventEl = resizeHandle.closest('.scheduler-event');
|
|
2743
|
+
const eventId = eventEl?.dataset['eventId'];
|
|
2744
|
+
const event = eventId ? this.config.getEventById(eventId) : null;
|
|
2745
|
+
if (event) {
|
|
2746
|
+
return {
|
|
2747
|
+
type: 'resize-handle',
|
|
2748
|
+
event,
|
|
2749
|
+
resizeHandle: resizeHandle.dataset['handle'],
|
|
2750
|
+
};
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
// Check for event
|
|
2754
|
+
const eventEl = element.closest('.scheduler-event:not(.preview)');
|
|
2755
|
+
if (eventEl) {
|
|
2756
|
+
const eventId = eventEl.dataset['eventId'];
|
|
2757
|
+
const event = eventId ? this.config.getEventById(eventId) : null;
|
|
2758
|
+
if (event) {
|
|
2759
|
+
return { type: 'event', event };
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
// Check for slot
|
|
2763
|
+
const slotEl = element.closest('.scheduler-time-slot, .scheduler-timeline-slot');
|
|
2764
|
+
if (slotEl) {
|
|
2765
|
+
return { type: 'slot', slotElement: slotEl };
|
|
2766
|
+
}
|
|
2767
|
+
return { type: 'none' };
|
|
2768
|
+
}
|
|
2769
|
+
// Mouse event handlers
|
|
2770
|
+
handleMouseDown(e) {
|
|
2771
|
+
if (!this.config.isEditable())
|
|
2772
|
+
return;
|
|
2773
|
+
const pointer = normalizeMouseEvent(e);
|
|
2774
|
+
const target = this.analyzeTarget(pointer.target);
|
|
2775
|
+
// Only handle drag-initiating targets
|
|
2776
|
+
if (target.type === 'none')
|
|
2777
|
+
return;
|
|
2778
|
+
// Check if selectable for slots
|
|
2779
|
+
if (target.type === 'slot' && !this.config.isSelectable())
|
|
2780
|
+
return;
|
|
2781
|
+
e.preventDefault();
|
|
2782
|
+
// For events/resize handles, set up mouse pan candidate (can pan if moved quickly)
|
|
2783
|
+
if (target.type === 'event' || target.type === 'resize-handle') {
|
|
2784
|
+
this.mouseStartPosition = { x: pointer.clientX, y: pointer.clientY };
|
|
2785
|
+
this.isMousePanCandidate = true;
|
|
2786
|
+
// Start timer - after this time, regular drag behavior takes over
|
|
2787
|
+
this.mousePanTimer = setTimeout(() => {
|
|
2788
|
+
// Timer expired without entering pan mode, so this is a drag operation
|
|
2789
|
+
this.isMousePanCandidate = false;
|
|
2790
|
+
this.mousePanTimer = null;
|
|
2791
|
+
}, DEFAULT_MOUSE_PAN_TIMEOUT);
|
|
2792
|
+
}
|
|
2793
|
+
this.callbacks.onPointerDown(pointer, target);
|
|
2794
|
+
}
|
|
2795
|
+
handleMouseMove(e) {
|
|
2796
|
+
const pointer = normalizeMouseEvent(e);
|
|
2797
|
+
// If in pan mode, continue panning
|
|
2798
|
+
if (this.isPanMode) {
|
|
2799
|
+
e.preventDefault();
|
|
2800
|
+
this.performPan(pointer.clientX, pointer.clientY);
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
// Check if we should enter pan mode (mouse on event + moved > threshold within timeout)
|
|
2804
|
+
if (this.isMousePanCandidate && this.mouseStartPosition) {
|
|
2805
|
+
const threshold = this.config.touchMoveThreshold ?? DEFAULT_TOUCH_MOVE_THRESHOLD;
|
|
2806
|
+
const distance = getPointerDistance(this.mouseStartPosition, {
|
|
2807
|
+
x: pointer.clientX,
|
|
2808
|
+
y: pointer.clientY,
|
|
2809
|
+
});
|
|
2810
|
+
if (distance > threshold) {
|
|
2811
|
+
// Cancel the pan timer
|
|
2812
|
+
if (this.mousePanTimer) {
|
|
2813
|
+
clearTimeout(this.mousePanTimer);
|
|
2814
|
+
this.mousePanTimer = null;
|
|
2815
|
+
}
|
|
2816
|
+
this.isMousePanCandidate = false;
|
|
2817
|
+
// Enter pan mode
|
|
2818
|
+
this.enterPanMode(pointer.clientX, pointer.clientY);
|
|
2819
|
+
e.preventDefault();
|
|
2820
|
+
return;
|
|
2821
|
+
}
|
|
2822
|
+
}
|
|
2823
|
+
this.callbacks.onPointerMove(pointer);
|
|
2824
|
+
}
|
|
2825
|
+
handleMouseUp(e) {
|
|
2826
|
+
const pointer = normalizeMouseEvent(e);
|
|
2827
|
+
// If in pan mode, exit it
|
|
2828
|
+
if (this.isPanMode) {
|
|
2829
|
+
this.exitPanMode();
|
|
2830
|
+
this.cleanupMousePanState();
|
|
2831
|
+
return;
|
|
2832
|
+
}
|
|
2833
|
+
// Clean up mouse pan candidate state
|
|
2834
|
+
this.cleanupMousePanState();
|
|
2835
|
+
this.callbacks.onPointerUp(pointer);
|
|
2836
|
+
}
|
|
2837
|
+
cleanupMousePanState() {
|
|
2838
|
+
if (this.mousePanTimer) {
|
|
2839
|
+
clearTimeout(this.mousePanTimer);
|
|
2840
|
+
this.mousePanTimer = null;
|
|
2841
|
+
}
|
|
2842
|
+
this.mouseStartPosition = null;
|
|
2843
|
+
this.isMousePanCandidate = false;
|
|
2844
|
+
}
|
|
2845
|
+
handleClick(e) {
|
|
2846
|
+
const pointer = normalizeMouseEvent(e);
|
|
2847
|
+
const target = this.analyzeTarget(pointer.target);
|
|
2848
|
+
// Skip event clicks - handled by drag manager
|
|
2849
|
+
if (target.type === 'event' || target.type === 'resize-handle') {
|
|
2850
|
+
return;
|
|
2851
|
+
}
|
|
2852
|
+
this.callbacks.onClick(pointer, target);
|
|
2853
|
+
}
|
|
2854
|
+
handleDblClick(e) {
|
|
2855
|
+
const pointer = normalizeMouseEvent(e);
|
|
2856
|
+
const target = this.analyzeTarget(pointer.target);
|
|
2857
|
+
this.callbacks.onDoubleClick(pointer, target);
|
|
2858
|
+
}
|
|
2859
|
+
// Touch event handlers
|
|
2860
|
+
handleTouchStart(e) {
|
|
2861
|
+
if (!this.config.isEditable())
|
|
2862
|
+
return;
|
|
2863
|
+
// Only handle single touch
|
|
2864
|
+
if (e.touches.length !== 1) {
|
|
2865
|
+
this.cancelTouchHold();
|
|
2866
|
+
return;
|
|
2867
|
+
}
|
|
2868
|
+
const pointer = normalizeTouchEvent(e);
|
|
2869
|
+
if (!pointer)
|
|
2870
|
+
return;
|
|
2871
|
+
const target = this.analyzeTarget(pointer.target);
|
|
2872
|
+
// Only handle drag-initiating targets
|
|
2873
|
+
if (target.type === 'none')
|
|
2874
|
+
return;
|
|
2875
|
+
if (target.type === 'slot' && !this.config.isSelectable())
|
|
2876
|
+
return;
|
|
2877
|
+
this.touchStartPosition = { x: pointer.clientX, y: pointer.clientY };
|
|
2878
|
+
this.touchHoldTarget = pointer.target;
|
|
2879
|
+
this.touchHoldPointer = pointer;
|
|
2880
|
+
// Track if touch started on an event (for pan support)
|
|
2881
|
+
this.panStartedOnEvent = target.type === 'event' || target.type === 'resize-handle';
|
|
2882
|
+
// Add element-level listeners IMMEDIATELY for ALL drag-initiating touches
|
|
2883
|
+
// CRITICAL: Use e.target (the actual touched element) not pointer.target
|
|
2884
|
+
// In shadow DOM, touch.target and e.target can differ due to event retargeting.
|
|
2885
|
+
// Only the listener on e.target will continue to receive events after DOM replacement
|
|
2886
|
+
// (e.g., when Lit re-renders and replaces the element due to visual feedback classes).
|
|
2887
|
+
const self = this;
|
|
2888
|
+
const touchedElement = e.target;
|
|
2889
|
+
// Add listener directly to the touched element - this is the ONLY listener
|
|
2890
|
+
// that will work after the element is replaced during re-render
|
|
2891
|
+
touchedElement.addEventListener('touchmove', function (evt) {
|
|
2892
|
+
self.handleTouchMove(evt);
|
|
2893
|
+
}, { passive: false });
|
|
2894
|
+
touchedElement.addEventListener('touchend', function (evt) {
|
|
2895
|
+
self.handleTouchEnd(evt);
|
|
2896
|
+
});
|
|
2897
|
+
touchedElement.addEventListener('touchcancel', function (evt) {
|
|
2898
|
+
self.handleTouchCancel(evt);
|
|
2899
|
+
});
|
|
2900
|
+
// Add visual feedback
|
|
2901
|
+
this.addTouchFeedback(pointer.target, 'pending');
|
|
2902
|
+
// Start hold timer
|
|
2903
|
+
const holdDuration = this.config.touchHoldDuration ?? DEFAULT_TOUCH_HOLD_DURATION;
|
|
2904
|
+
this.touchHoldTimer = setTimeout(() => {
|
|
2905
|
+
this.activateTouchDragMode(pointer, target);
|
|
2906
|
+
}, holdDuration);
|
|
2907
|
+
}
|
|
2908
|
+
handleTouchMove(e) {
|
|
2909
|
+
if (e.touches.length !== 1) {
|
|
2910
|
+
this.cancelTouchHold();
|
|
2911
|
+
this.exitPanMode();
|
|
2912
|
+
return;
|
|
2913
|
+
}
|
|
2914
|
+
const pointer = normalizeTouchEvent(e);
|
|
2915
|
+
if (!pointer)
|
|
2916
|
+
return;
|
|
2917
|
+
// If in pan mode, continue panning
|
|
2918
|
+
if (this.isPanMode) {
|
|
2919
|
+
e.preventDefault();
|
|
2920
|
+
this.performPan(pointer.clientX, pointer.clientY);
|
|
2921
|
+
return;
|
|
2922
|
+
}
|
|
2923
|
+
// If we have a pending touch hold, check if user moved too much
|
|
2924
|
+
if (this.touchHoldTimer && this.touchStartPosition) {
|
|
2925
|
+
const threshold = this.config.touchMoveThreshold ?? DEFAULT_TOUCH_MOVE_THRESHOLD;
|
|
2926
|
+
const distance = getPointerDistance(this.touchStartPosition, {
|
|
2927
|
+
x: pointer.clientX,
|
|
2928
|
+
y: pointer.clientY,
|
|
2929
|
+
});
|
|
2930
|
+
if (distance > threshold) {
|
|
2931
|
+
// User moved too much during hold period
|
|
2932
|
+
this.cancelTouchHold();
|
|
2933
|
+
// If touch started on an event, enter pan mode instead of allowing native scroll
|
|
2934
|
+
if (this.panStartedOnEvent) {
|
|
2935
|
+
this.enterPanMode(pointer.clientX, pointer.clientY);
|
|
2936
|
+
e.preventDefault();
|
|
2937
|
+
}
|
|
2938
|
+
// Otherwise, allow native scroll (no preventDefault)
|
|
2939
|
+
return;
|
|
2940
|
+
}
|
|
2941
|
+
// Still waiting for hold - prevent scroll while waiting
|
|
2942
|
+
// This is critical: we must prevent the browser from starting scroll
|
|
2943
|
+
// intervention during the hold period, otherwise it will take over
|
|
2944
|
+
// and subsequent touchmove events become non-cancelable
|
|
2945
|
+
e.preventDefault();
|
|
2946
|
+
return;
|
|
2947
|
+
}
|
|
2948
|
+
// If in touch drag mode, handle the drag
|
|
2949
|
+
if (this.isTouchDragMode) {
|
|
2950
|
+
e.preventDefault();
|
|
2951
|
+
this.callbacks.onPointerMove(pointer);
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
handleTouchEnd(e) {
|
|
2955
|
+
const pointer = normalizeTouchEvent(e);
|
|
2956
|
+
// If in pan mode, exit it
|
|
2957
|
+
if (this.isPanMode) {
|
|
2958
|
+
this.exitPanMode();
|
|
2959
|
+
return;
|
|
2960
|
+
}
|
|
2961
|
+
// If we had a pending hold that never activated, treat as tap
|
|
2962
|
+
if (this.touchHoldTimer) {
|
|
2963
|
+
this.cancelTouchHold();
|
|
2964
|
+
if (pointer && this.touchHoldTarget) {
|
|
2965
|
+
const target = this.analyzeTarget(this.touchHoldTarget);
|
|
2966
|
+
this.callbacks.onClick(pointer, target);
|
|
2967
|
+
}
|
|
2968
|
+
this.touchHoldTarget = null;
|
|
2969
|
+
this.touchHoldPointer = null;
|
|
2970
|
+
return;
|
|
2971
|
+
}
|
|
2972
|
+
// If in touch drag mode, finalize
|
|
2973
|
+
if (this.isTouchDragMode && pointer) {
|
|
2974
|
+
this.callbacks.onPointerUp(pointer);
|
|
2975
|
+
this.exitTouchDragMode();
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
handleTouchCancel(_e) {
|
|
2979
|
+
this.cancelTouchHold();
|
|
2980
|
+
this.exitPanMode();
|
|
2981
|
+
if (this.isTouchDragMode) {
|
|
2982
|
+
// Notify pointer up to cancel the drag
|
|
2983
|
+
if (this.touchStartPosition) {
|
|
2984
|
+
this.callbacks.onPointerUp({
|
|
2985
|
+
pointerId: 0,
|
|
2986
|
+
pointerType: 'touch',
|
|
2987
|
+
clientX: this.touchStartPosition.x,
|
|
2988
|
+
clientY: this.touchStartPosition.y,
|
|
2989
|
+
originalEvent: _e,
|
|
2990
|
+
target: this.touchHoldTarget ?? document.body,
|
|
2991
|
+
isPrimary: true,
|
|
2992
|
+
});
|
|
2993
|
+
}
|
|
2994
|
+
this.exitTouchDragMode();
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
// Touch helpers
|
|
2998
|
+
activateTouchDragMode(pointer, target) {
|
|
2999
|
+
this.touchHoldTimer = null;
|
|
3000
|
+
this.touchStartPosition = null; // Clear so touchmove doesn't think we're still pending
|
|
3001
|
+
// Trigger haptic feedback
|
|
3002
|
+
this.triggerHapticFeedback();
|
|
3003
|
+
// Enter touch drag mode
|
|
3004
|
+
this.isTouchDragMode = true;
|
|
3005
|
+
// Document listeners were already added in handleTouchStart for event touches
|
|
3006
|
+
// For slot touches, add them now
|
|
3007
|
+
if (!this.touchHoldTarget?.closest('.scheduler-event')) {
|
|
3008
|
+
document.addEventListener('touchmove', this.boundHandleTouchMove, { passive: false });
|
|
3009
|
+
document.addEventListener('touchend', this.boundHandleTouchEnd);
|
|
3010
|
+
document.addEventListener('touchcancel', this.boundHandleTouchCancel);
|
|
3011
|
+
}
|
|
3012
|
+
// Update visual feedback
|
|
3013
|
+
if (this.touchHoldTarget) {
|
|
3014
|
+
this.removeTouchFeedback(this.touchHoldTarget, 'pending');
|
|
3015
|
+
this.addTouchFeedback(this.touchHoldTarget, 'active');
|
|
3016
|
+
}
|
|
3017
|
+
// Add container class
|
|
3018
|
+
const container = this.config.shadowRoot.querySelector('.scheduler-container');
|
|
3019
|
+
container?.classList.add('touch-drag-mode');
|
|
3020
|
+
// Apply scroll blocking to document body and scheduler-content
|
|
3021
|
+
this.applyScrollBlock();
|
|
3022
|
+
// Notify callback
|
|
3023
|
+
this.callbacks.onTouchDragActivated?.();
|
|
3024
|
+
// Start the drag immediately (skip pending threshold for touch)
|
|
3025
|
+
this.callbacks.onPointerDown(pointer, target, true);
|
|
3026
|
+
}
|
|
3027
|
+
cancelTouchHold() {
|
|
3028
|
+
if (this.touchHoldTimer) {
|
|
3029
|
+
clearTimeout(this.touchHoldTimer);
|
|
3030
|
+
this.touchHoldTimer = null;
|
|
3031
|
+
}
|
|
3032
|
+
// Remove document listeners that were added in handleTouchStart
|
|
3033
|
+
document.removeEventListener('touchmove', this.boundHandleTouchMove);
|
|
3034
|
+
document.removeEventListener('touchend', this.boundHandleTouchEnd);
|
|
3035
|
+
document.removeEventListener('touchcancel', this.boundHandleTouchCancel);
|
|
3036
|
+
// Remove pending visual feedback
|
|
3037
|
+
if (this.touchHoldTarget) {
|
|
3038
|
+
this.removeTouchFeedback(this.touchHoldTarget, 'pending');
|
|
3039
|
+
this.removeTouchFeedback(this.touchHoldTarget, 'active');
|
|
3040
|
+
}
|
|
3041
|
+
// Note: Don't reset touchStartPosition here as it may be needed for pan mode
|
|
3042
|
+
// Also don't reset panStartedOnEvent - it's needed after cancelTouchHold
|
|
3043
|
+
}
|
|
3044
|
+
exitTouchDragMode() {
|
|
3045
|
+
this.isTouchDragMode = false;
|
|
3046
|
+
this.touchStartPosition = null;
|
|
3047
|
+
this.touchHoldTarget = null;
|
|
3048
|
+
this.touchHoldPointer = null;
|
|
3049
|
+
// Remove document-level listeners added during drag mode
|
|
3050
|
+
document.removeEventListener('touchmove', this.boundHandleTouchMove);
|
|
3051
|
+
document.removeEventListener('touchend', this.boundHandleTouchEnd);
|
|
3052
|
+
document.removeEventListener('touchcancel', this.boundHandleTouchCancel);
|
|
3053
|
+
// Remove container class
|
|
3054
|
+
const container = this.config.shadowRoot.querySelector('.scheduler-container');
|
|
3055
|
+
container?.classList.remove('touch-drag-mode');
|
|
3056
|
+
// Remove scroll blocking
|
|
3057
|
+
this.removeScrollBlock();
|
|
3058
|
+
// Remove all feedback classes
|
|
3059
|
+
this.config.shadowRoot
|
|
3060
|
+
.querySelectorAll('.touch-hold-active, .touch-hold-pending')
|
|
3061
|
+
.forEach((el) => {
|
|
3062
|
+
el.classList.remove('touch-hold-active', 'touch-hold-pending');
|
|
3063
|
+
});
|
|
3064
|
+
// Notify callback
|
|
3065
|
+
this.callbacks.onTouchDragDeactivated?.();
|
|
3066
|
+
}
|
|
3067
|
+
addTouchFeedback(element, type) {
|
|
3068
|
+
const targetEl = element.closest('.scheduler-event, .scheduler-time-slot, .scheduler-timeline-slot');
|
|
3069
|
+
targetEl?.classList.add(`touch-hold-${type}`);
|
|
3070
|
+
}
|
|
3071
|
+
removeTouchFeedback(element, type) {
|
|
3072
|
+
const targetEl = element.closest('.scheduler-event, .scheduler-time-slot, .scheduler-timeline-slot');
|
|
3073
|
+
targetEl?.classList.remove(`touch-hold-${type}`);
|
|
3074
|
+
}
|
|
3075
|
+
triggerHapticFeedback() {
|
|
3076
|
+
// Vibration API removed - can cause issues on some devices
|
|
3077
|
+
}
|
|
3078
|
+
// Pan helpers
|
|
3079
|
+
enterPanMode(clientX, clientY) {
|
|
3080
|
+
const container = this.callbacks.getScrollContainer?.();
|
|
3081
|
+
if (!container)
|
|
3082
|
+
return;
|
|
3083
|
+
this.isPanMode = true;
|
|
3084
|
+
this.panStartPosition = { x: clientX, y: clientY };
|
|
3085
|
+
this.panStartScroll = { left: container.scrollLeft, top: container.scrollTop };
|
|
3086
|
+
// Add visual feedback for pan mode
|
|
3087
|
+
container.classList.add('pan-mode');
|
|
3088
|
+
// Remove hold feedback if any
|
|
3089
|
+
if (this.touchHoldTarget) {
|
|
3090
|
+
this.removeTouchFeedback(this.touchHoldTarget, 'pending');
|
|
3091
|
+
this.removeTouchFeedback(this.touchHoldTarget, 'active');
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
exitPanMode() {
|
|
3095
|
+
if (!this.isPanMode)
|
|
3096
|
+
return;
|
|
3097
|
+
this.isPanMode = false;
|
|
3098
|
+
this.panStartPosition = null;
|
|
3099
|
+
this.panStartScroll = null;
|
|
3100
|
+
this.panStartedOnEvent = false;
|
|
3101
|
+
this.touchStartPosition = null;
|
|
3102
|
+
this.touchHoldTarget = null;
|
|
3103
|
+
// Remove visual feedback
|
|
3104
|
+
const container = this.callbacks.getScrollContainer?.();
|
|
3105
|
+
container?.classList.remove('pan-mode');
|
|
3106
|
+
}
|
|
3107
|
+
performPan(clientX, clientY) {
|
|
3108
|
+
if (!this.isPanMode || !this.panStartPosition || !this.panStartScroll)
|
|
3109
|
+
return;
|
|
3110
|
+
const container = this.callbacks.getScrollContainer?.();
|
|
3111
|
+
if (!container)
|
|
3112
|
+
return;
|
|
3113
|
+
// Calculate delta from start position
|
|
3114
|
+
const deltaX = this.panStartPosition.x - clientX;
|
|
3115
|
+
const deltaY = this.panStartPosition.y - clientY;
|
|
3116
|
+
// Apply scroll
|
|
3117
|
+
container.scrollLeft = this.panStartScroll.left + deltaX;
|
|
3118
|
+
container.scrollTop = this.panStartScroll.top + deltaY;
|
|
3119
|
+
}
|
|
3120
|
+
// Scroll blocking helpers
|
|
3121
|
+
applyScrollBlock() {
|
|
3122
|
+
// Save current body styles for restoration
|
|
3123
|
+
this.savedBodyStyles = {
|
|
3124
|
+
overflow: document.body.style.overflow,
|
|
3125
|
+
touchAction: document.body.style.touchAction,
|
|
3126
|
+
};
|
|
3127
|
+
// Block scrolling on document body
|
|
3128
|
+
document.body.style.overflow = 'hidden';
|
|
3129
|
+
document.body.style.touchAction = 'none';
|
|
3130
|
+
// Block scrolling on scheduler-content
|
|
3131
|
+
const scrollContainer = this.callbacks.getScrollContainer?.();
|
|
3132
|
+
if (scrollContainer) {
|
|
3133
|
+
scrollContainer.classList.add('scroll-blocked');
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
removeScrollBlock() {
|
|
3137
|
+
// Restore body styles
|
|
3138
|
+
if (this.savedBodyStyles) {
|
|
3139
|
+
document.body.style.overflow = this.savedBodyStyles.overflow;
|
|
3140
|
+
document.body.style.touchAction = this.savedBodyStyles.touchAction;
|
|
3141
|
+
this.savedBodyStyles = null;
|
|
3142
|
+
}
|
|
3143
|
+
// Remove scroll blocking from scheduler-content
|
|
3144
|
+
const scrollContainer = this.callbacks.getScrollContainer?.();
|
|
3145
|
+
if (scrollContainer) {
|
|
3146
|
+
scrollContainer.classList.remove('scroll-blocked');
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
|
|
3151
|
+
/**
|
|
3152
|
+
* Handles dispatching custom events from the scheduler.
|
|
3153
|
+
* Centralizes event emission logic for consistency.
|
|
3154
|
+
*/
|
|
3155
|
+
class SchedulerEventEmitter {
|
|
3156
|
+
constructor(host) {
|
|
3157
|
+
this.host = host;
|
|
3158
|
+
}
|
|
3159
|
+
/**
|
|
3160
|
+
* Emit a scheduler custom event.
|
|
3161
|
+
* The event will bubble up through the DOM.
|
|
3162
|
+
*/
|
|
3163
|
+
emit(event) {
|
|
3164
|
+
const { type, ...detail } = event;
|
|
3165
|
+
this.host.dispatchEvent(new CustomEvent(type, {
|
|
3166
|
+
detail,
|
|
3167
|
+
bubbles: true,
|
|
3168
|
+
}));
|
|
3169
|
+
}
|
|
3170
|
+
/**
|
|
3171
|
+
* Emit an event-click event.
|
|
3172
|
+
*/
|
|
3173
|
+
emitEventClick(event, originalEvent) {
|
|
3174
|
+
this.emit({ type: 'event-click', event, originalEvent });
|
|
3175
|
+
}
|
|
3176
|
+
/**
|
|
3177
|
+
* Emit an event-dblclick event.
|
|
3178
|
+
*/
|
|
3179
|
+
emitEventDblClick(event, originalEvent) {
|
|
3180
|
+
this.emit({ type: 'event-dblclick', event, originalEvent });
|
|
3181
|
+
}
|
|
3182
|
+
/**
|
|
3183
|
+
* Emit an event-create event.
|
|
3184
|
+
*/
|
|
3185
|
+
emitEventCreate(event, originalEvent) {
|
|
3186
|
+
this.emit({ type: 'event-create', event, originalEvent });
|
|
3187
|
+
}
|
|
3188
|
+
/**
|
|
3189
|
+
* Emit an event-update event.
|
|
3190
|
+
*/
|
|
3191
|
+
emitEventUpdate(event, oldEvent, originalEvent) {
|
|
3192
|
+
this.emit({ type: 'event-update', event, oldEvent, originalEvent });
|
|
3193
|
+
}
|
|
3194
|
+
/**
|
|
3195
|
+
* Emit an event-delete event.
|
|
3196
|
+
*/
|
|
3197
|
+
emitEventDelete(event) {
|
|
3198
|
+
this.emit({ type: 'event-delete', event });
|
|
3199
|
+
}
|
|
3200
|
+
/**
|
|
3201
|
+
* Emit a date-click event.
|
|
3202
|
+
*/
|
|
3203
|
+
emitDateClick(date, originalEvent) {
|
|
3204
|
+
this.emit({ type: 'date-click', date, originalEvent });
|
|
3205
|
+
}
|
|
3206
|
+
/**
|
|
3207
|
+
* Emit a view-change event.
|
|
3208
|
+
*/
|
|
3209
|
+
emitViewChange(view, date) {
|
|
3210
|
+
this.emit({ type: 'view-change', view, date });
|
|
3211
|
+
}
|
|
3212
|
+
/**
|
|
3213
|
+
* Emit a selection-change event.
|
|
3214
|
+
*/
|
|
3215
|
+
emitSelectionChange(selectedEvent) {
|
|
3216
|
+
this.emit({ type: 'selection-change', selectedEvent });
|
|
3217
|
+
}
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
/**
|
|
3221
|
+
* MpScheduler Web Component
|
|
3222
|
+
*
|
|
3223
|
+
* A fully-featured scheduler/calendar component.
|
|
3224
|
+
* Refactored for clarity with separated concerns:
|
|
3225
|
+
* - DragManager: Handles all drag operations
|
|
3226
|
+
* - InputHandler: Normalizes mouse/touch input
|
|
3227
|
+
* - SchedulerEventEmitter: Dispatches custom events
|
|
3228
|
+
*/
|
|
3229
|
+
class MpScheduler extends LitElement {
|
|
3230
|
+
static { this.styles = [schedulerStyles]; }
|
|
3231
|
+
static get observedAttributes() {
|
|
3232
|
+
return [
|
|
3233
|
+
...(super.observedAttributes ?? []),
|
|
3234
|
+
'view',
|
|
3235
|
+
'date',
|
|
3236
|
+
'locale',
|
|
3237
|
+
'first-day-of-week',
|
|
3238
|
+
'slot-duration',
|
|
3239
|
+
'time-format',
|
|
3240
|
+
'editable',
|
|
3241
|
+
'selectable',
|
|
3242
|
+
];
|
|
3243
|
+
}
|
|
3244
|
+
constructor() {
|
|
3245
|
+
super();
|
|
3246
|
+
this.currentView = null;
|
|
3247
|
+
this.currentViewType = null;
|
|
3248
|
+
this.contentContainer = null;
|
|
3249
|
+
this.inputHandler = null;
|
|
3250
|
+
// Track previous state for change detection
|
|
3251
|
+
this.previousView = null;
|
|
3252
|
+
this.previousDate = null;
|
|
3253
|
+
this.previousSelectedEventId = null;
|
|
3254
|
+
// RAF scheduling for drag updates
|
|
3255
|
+
this.pendingDragUpdate = null;
|
|
3256
|
+
this.latestDragState = null;
|
|
3257
|
+
// Now indicator update timer
|
|
3258
|
+
this.nowIndicatorTimer = null;
|
|
3259
|
+
this.stateManager = new SchedulerStateManager();
|
|
3260
|
+
this.eventEmitter = new SchedulerEventEmitter(this);
|
|
3261
|
+
// Initialize drag manager (input handler is deferred to firstUpdated()
|
|
3262
|
+
// because it needs the shadow root, which Lit creates after construction).
|
|
3263
|
+
this.dragManager = new DragManager(this.stateManager);
|
|
3264
|
+
this.dragManager.setSlotResolver((x, y) => this.getSlotAtPosition(x, y));
|
|
3265
|
+
// Bind keyboard handler
|
|
3266
|
+
this.boundHandleKeyDown = this.handleKeyDown.bind(this);
|
|
3267
|
+
// Subscribe to state changes
|
|
3268
|
+
this.stateManager.subscribe((state) => this.onStateChange(state));
|
|
3269
|
+
}
|
|
3270
|
+
connectedCallback() {
|
|
3271
|
+
super.connectedCallback();
|
|
3272
|
+
if (this.inputHandler) {
|
|
3273
|
+
this.inputHandler.attach();
|
|
3274
|
+
}
|
|
3275
|
+
this.addEventListener('keydown', this.boundHandleKeyDown);
|
|
3276
|
+
// Start now indicator update timer (every minute)
|
|
3277
|
+
this.startNowIndicatorTimer();
|
|
3278
|
+
}
|
|
3279
|
+
disconnectedCallback() {
|
|
3280
|
+
this.inputHandler?.detach();
|
|
3281
|
+
this.removeEventListener('keydown', this.boundHandleKeyDown);
|
|
3282
|
+
this.currentView?.destroy();
|
|
3283
|
+
this.dragManager.destroy();
|
|
3284
|
+
// Stop now indicator timer
|
|
3285
|
+
this.stopNowIndicatorTimer();
|
|
3286
|
+
// Cancel any pending RAF
|
|
3287
|
+
if (this.pendingDragUpdate !== null) {
|
|
3288
|
+
cancelAnimationFrame(this.pendingDragUpdate);
|
|
3289
|
+
this.pendingDragUpdate = null;
|
|
3290
|
+
}
|
|
3291
|
+
this.latestDragState = null;
|
|
3292
|
+
super.disconnectedCallback();
|
|
3293
|
+
}
|
|
3294
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
3295
|
+
super.attributeChangedCallback(name, oldValue, newValue);
|
|
3296
|
+
if (oldValue === newValue)
|
|
3297
|
+
return;
|
|
3298
|
+
switch (name) {
|
|
3299
|
+
case 'view':
|
|
3300
|
+
if (newValue && ['year', 'month', 'week', 'day', 'timeline'].includes(newValue)) {
|
|
3301
|
+
this.stateManager.setView(newValue);
|
|
3302
|
+
}
|
|
3303
|
+
break;
|
|
3304
|
+
case 'date':
|
|
3305
|
+
if (newValue) {
|
|
3306
|
+
this.stateManager.setDate(new Date(newValue));
|
|
3307
|
+
}
|
|
3308
|
+
break;
|
|
3309
|
+
case 'locale':
|
|
3310
|
+
if (newValue) {
|
|
3311
|
+
this.stateManager.setOptions({ locale: newValue });
|
|
3312
|
+
}
|
|
3313
|
+
break;
|
|
3314
|
+
case 'first-day-of-week':
|
|
3315
|
+
if (newValue) {
|
|
3316
|
+
const day = parseInt(newValue, 10);
|
|
3317
|
+
if (day >= 0 && day <= 6) {
|
|
3318
|
+
this.stateManager.setOptions({
|
|
3319
|
+
firstDayOfWeek: day,
|
|
3320
|
+
});
|
|
3321
|
+
}
|
|
3322
|
+
}
|
|
3323
|
+
break;
|
|
3324
|
+
case 'slot-duration':
|
|
3325
|
+
if (newValue) {
|
|
3326
|
+
this.stateManager.setOptions({ slotDuration: parseInt(newValue, 10) });
|
|
3327
|
+
}
|
|
3328
|
+
break;
|
|
3329
|
+
case 'time-format':
|
|
3330
|
+
if (newValue && (newValue === '12h' || newValue === '24h')) {
|
|
3331
|
+
this.stateManager.setOptions({ timeFormat: newValue });
|
|
3332
|
+
}
|
|
3333
|
+
break;
|
|
3334
|
+
case 'editable':
|
|
3335
|
+
this.stateManager.setOptions({ editable: newValue !== 'false' });
|
|
3336
|
+
break;
|
|
3337
|
+
case 'selectable':
|
|
3338
|
+
this.stateManager.setOptions({ selectable: newValue !== 'false' });
|
|
3339
|
+
break;
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
// ============================================
|
|
3343
|
+
// Public API
|
|
3344
|
+
// ============================================
|
|
3345
|
+
get view() {
|
|
3346
|
+
return this.stateManager.getState().view;
|
|
3347
|
+
}
|
|
3348
|
+
set view(value) {
|
|
3349
|
+
this.stateManager.setView(value);
|
|
3350
|
+
}
|
|
3351
|
+
get date() {
|
|
3352
|
+
return this.stateManager.getState().date;
|
|
3353
|
+
}
|
|
3354
|
+
set date(value) {
|
|
3355
|
+
this.stateManager.setDate(value);
|
|
3356
|
+
}
|
|
3357
|
+
get events() {
|
|
3358
|
+
return this.stateManager.getState().events;
|
|
3359
|
+
}
|
|
3360
|
+
set events(value) {
|
|
3361
|
+
this.stateManager.setEvents(value);
|
|
3362
|
+
}
|
|
3363
|
+
get resources() {
|
|
3364
|
+
return this.stateManager.getState().resources;
|
|
3365
|
+
}
|
|
3366
|
+
set resources(value) {
|
|
3367
|
+
this.stateManager.setResources(value);
|
|
3368
|
+
}
|
|
3369
|
+
get options() {
|
|
3370
|
+
return this.stateManager.getState().options;
|
|
3371
|
+
}
|
|
3372
|
+
set options(value) {
|
|
3373
|
+
this.stateManager.setOptions(value);
|
|
3374
|
+
}
|
|
3375
|
+
get selectedEvent() {
|
|
3376
|
+
return this.stateManager.getState().selectedEvent;
|
|
3377
|
+
}
|
|
3378
|
+
set selectedEvent(value) {
|
|
3379
|
+
this.stateManager.setSelectedEvent(value);
|
|
3380
|
+
}
|
|
3381
|
+
get selectedRange() {
|
|
3382
|
+
const state = this.stateManager.getState();
|
|
3383
|
+
if (state.previewEvent) {
|
|
3384
|
+
return { start: state.previewEvent.start, end: state.previewEvent.end };
|
|
3385
|
+
}
|
|
3386
|
+
return null;
|
|
3387
|
+
}
|
|
3388
|
+
next() {
|
|
3389
|
+
this.stateManager.next();
|
|
3390
|
+
}
|
|
3391
|
+
prev() {
|
|
3392
|
+
this.stateManager.prev();
|
|
3393
|
+
}
|
|
3394
|
+
today() {
|
|
3395
|
+
this.stateManager.today();
|
|
3396
|
+
}
|
|
3397
|
+
gotoDate(date) {
|
|
3398
|
+
this.stateManager.gotoDate(date);
|
|
3399
|
+
}
|
|
3400
|
+
changeView(view) {
|
|
3401
|
+
this.stateManager.setView(view);
|
|
3402
|
+
}
|
|
3403
|
+
addEvent(event) {
|
|
3404
|
+
this.stateManager.addEvent(event);
|
|
3405
|
+
}
|
|
3406
|
+
updateEvent(event) {
|
|
3407
|
+
this.stateManager.updateEvent(event);
|
|
3408
|
+
}
|
|
3409
|
+
removeEvent(eventId) {
|
|
3410
|
+
this.stateManager.removeEvent(eventId);
|
|
3411
|
+
}
|
|
3412
|
+
getEventById(eventId) {
|
|
3413
|
+
return this.events.find((e) => e.id === eventId) ?? null;
|
|
3414
|
+
}
|
|
3415
|
+
refetchEvents() {
|
|
3416
|
+
this.currentView?.update(this.stateManager.getState());
|
|
3417
|
+
}
|
|
3418
|
+
// ============================================
|
|
3419
|
+
// Rendering
|
|
3420
|
+
// ============================================
|
|
3421
|
+
render() {
|
|
3422
|
+
return html `
|
|
3423
|
+
<div class="scheduler-container">
|
|
3424
|
+
<header class="scheduler-header"></header>
|
|
3425
|
+
<div class="scheduler-content"></div>
|
|
3426
|
+
</div>
|
|
3427
|
+
`;
|
|
3428
|
+
}
|
|
3429
|
+
firstUpdated() {
|
|
3430
|
+
const headerEl = this.shadowRoot.querySelector('.scheduler-header');
|
|
3431
|
+
this.contentContainer = this.shadowRoot.querySelector('.scheduler-content');
|
|
3432
|
+
this.populateHeader(headerEl);
|
|
3433
|
+
// Construct InputHandler now that shadowRoot is available, then attach.
|
|
3434
|
+
this.inputHandler = new InputHandler({
|
|
3435
|
+
shadowRoot: this.shadowRoot,
|
|
3436
|
+
getEventById: (id) => this.getEventById(id),
|
|
3437
|
+
isEditable: () => this.stateManager.getState().options.editable ?? true,
|
|
3438
|
+
isSelectable: () => this.stateManager.getState().options.selectable ?? true,
|
|
3439
|
+
}, {
|
|
3440
|
+
onPointerDown: (pointer, target, immediate) => this.handlePointerDown(pointer, target, immediate),
|
|
3441
|
+
onPointerMove: (pointer) => this.handlePointerMove(pointer),
|
|
3442
|
+
onPointerUp: (pointer) => this.handlePointerUp(pointer),
|
|
3443
|
+
onClick: (pointer, target) => this.handleClick(pointer, target),
|
|
3444
|
+
onDoubleClick: (pointer, target) => this.handleDoubleClick(pointer, target),
|
|
3445
|
+
getScrollContainer: () => this.contentContainer,
|
|
3446
|
+
});
|
|
3447
|
+
this.inputHandler.attach();
|
|
3448
|
+
this.renderView();
|
|
3449
|
+
}
|
|
3450
|
+
populateHeader(header) {
|
|
3451
|
+
// Navigation
|
|
3452
|
+
const nav = document.createElement('nav');
|
|
3453
|
+
nav.className = 'scheduler-nav';
|
|
3454
|
+
const prevBtn = document.createElement('button');
|
|
3455
|
+
prevBtn.textContent = '‹';
|
|
3456
|
+
prevBtn.title = 'Previous';
|
|
3457
|
+
prevBtn.addEventListener('click', () => this.prev());
|
|
3458
|
+
const nextBtn = document.createElement('button');
|
|
3459
|
+
nextBtn.textContent = '›';
|
|
3460
|
+
nextBtn.title = 'Next';
|
|
3461
|
+
nextBtn.addEventListener('click', () => this.next());
|
|
3462
|
+
const todayBtn = document.createElement('button');
|
|
3463
|
+
todayBtn.textContent = 'Today';
|
|
3464
|
+
todayBtn.addEventListener('click', () => this.today());
|
|
3465
|
+
nav.appendChild(prevBtn);
|
|
3466
|
+
nav.appendChild(nextBtn);
|
|
3467
|
+
nav.appendChild(todayBtn);
|
|
3468
|
+
// Title
|
|
3469
|
+
const title = document.createElement('div');
|
|
3470
|
+
title.className = 'scheduler-title';
|
|
3471
|
+
this.updateTitle(title);
|
|
3472
|
+
// View switcher
|
|
3473
|
+
const viewSwitcher = document.createElement('div');
|
|
3474
|
+
viewSwitcher.className = 'scheduler-view-switcher';
|
|
3475
|
+
const views = [
|
|
3476
|
+
{ key: 'year', label: 'Year' },
|
|
3477
|
+
{ key: 'month', label: 'Month' },
|
|
3478
|
+
{ key: 'week', label: 'Week' },
|
|
3479
|
+
{ key: 'day', label: 'Day' },
|
|
3480
|
+
{ key: 'timeline', label: 'Timeline' },
|
|
3481
|
+
];
|
|
3482
|
+
for (const { key, label } of views) {
|
|
3483
|
+
const btn = document.createElement('button');
|
|
3484
|
+
btn.textContent = label;
|
|
3485
|
+
btn.dataset['view'] = key;
|
|
3486
|
+
if (key === this.view) {
|
|
3487
|
+
btn.classList.add('active');
|
|
3488
|
+
}
|
|
3489
|
+
btn.addEventListener('click', () => this.changeView(key));
|
|
3490
|
+
viewSwitcher.appendChild(btn);
|
|
3491
|
+
}
|
|
3492
|
+
header.appendChild(nav);
|
|
3493
|
+
header.appendChild(title);
|
|
3494
|
+
header.appendChild(viewSwitcher);
|
|
3495
|
+
}
|
|
3496
|
+
updateTitle(titleEl) {
|
|
3497
|
+
const title = titleEl ?? this.shadowRoot.querySelector('.scheduler-title');
|
|
3498
|
+
if (!title)
|
|
3499
|
+
return;
|
|
3500
|
+
const state = this.stateManager.getState();
|
|
3501
|
+
const { date, view, options } = state;
|
|
3502
|
+
let titleText = '';
|
|
3503
|
+
switch (view) {
|
|
3504
|
+
case 'year':
|
|
3505
|
+
titleText = date.getFullYear().toString();
|
|
3506
|
+
break;
|
|
3507
|
+
case 'month':
|
|
3508
|
+
titleText = dateService.formatDate(date, options.locale, {
|
|
3509
|
+
month: 'long',
|
|
3510
|
+
year: 'numeric',
|
|
3511
|
+
});
|
|
3512
|
+
break;
|
|
3513
|
+
case 'week':
|
|
3514
|
+
case 'timeline': {
|
|
3515
|
+
const weekStart = dateService.getWeekStart(date, options.firstDayOfWeek);
|
|
3516
|
+
const weekEnd = dateService.addDays(weekStart, 6);
|
|
3517
|
+
titleText = `${dateService.formatDate(weekStart, options.locale, {
|
|
3518
|
+
month: 'short',
|
|
3519
|
+
day: 'numeric',
|
|
3520
|
+
})} - ${dateService.formatDate(weekEnd, options.locale, {
|
|
3521
|
+
month: 'short',
|
|
3522
|
+
day: 'numeric',
|
|
3523
|
+
year: 'numeric',
|
|
3524
|
+
})}`;
|
|
3525
|
+
break;
|
|
3526
|
+
}
|
|
3527
|
+
case 'day':
|
|
3528
|
+
titleText = dateService.formatDate(date, options.locale, {
|
|
3529
|
+
weekday: 'long',
|
|
3530
|
+
month: 'long',
|
|
3531
|
+
day: 'numeric',
|
|
3532
|
+
year: 'numeric',
|
|
3533
|
+
});
|
|
3534
|
+
break;
|
|
3535
|
+
}
|
|
3536
|
+
title.textContent = titleText;
|
|
3537
|
+
}
|
|
3538
|
+
renderView() {
|
|
3539
|
+
if (!this.contentContainer)
|
|
3540
|
+
return;
|
|
3541
|
+
this.currentView?.destroy();
|
|
3542
|
+
const state = this.stateManager.getState();
|
|
3543
|
+
switch (state.view) {
|
|
3544
|
+
case 'year':
|
|
3545
|
+
this.currentView = new YearView(this.contentContainer, state);
|
|
3546
|
+
this.currentViewType = 'year';
|
|
3547
|
+
break;
|
|
3548
|
+
case 'month':
|
|
3549
|
+
this.currentView = new MonthView(this.contentContainer, state);
|
|
3550
|
+
this.currentViewType = 'month';
|
|
3551
|
+
break;
|
|
3552
|
+
case 'week':
|
|
3553
|
+
this.currentView = new WeekView(this.contentContainer, state);
|
|
3554
|
+
this.currentViewType = 'week';
|
|
3555
|
+
break;
|
|
3556
|
+
case 'day':
|
|
3557
|
+
this.currentView = new DayView(this.contentContainer, state);
|
|
3558
|
+
this.currentViewType = 'day';
|
|
3559
|
+
break;
|
|
3560
|
+
case 'timeline':
|
|
3561
|
+
this.currentView = new TimelineView(this.contentContainer, state);
|
|
3562
|
+
this.currentViewType = 'timeline';
|
|
3563
|
+
break;
|
|
3564
|
+
}
|
|
3565
|
+
this.currentView?.render();
|
|
3566
|
+
}
|
|
3567
|
+
// ============================================
|
|
3568
|
+
// State Change Handling
|
|
3569
|
+
// ============================================
|
|
3570
|
+
onStateChange(state) {
|
|
3571
|
+
this.detectAndEmitChanges(state);
|
|
3572
|
+
this.updateUI(state);
|
|
3573
|
+
}
|
|
3574
|
+
detectAndEmitChanges(state) {
|
|
3575
|
+
const viewChanged = this.previousView !== null && this.previousView !== state.view;
|
|
3576
|
+
const dateChanged = this.previousDate !== null &&
|
|
3577
|
+
this.previousDate.getTime() !== state.date.getTime();
|
|
3578
|
+
const selectedEventId = state.selectedEvent?.id ?? null;
|
|
3579
|
+
const selectionChanged = this.previousSelectedEventId !== null &&
|
|
3580
|
+
this.previousSelectedEventId !== selectedEventId;
|
|
3581
|
+
if (viewChanged || dateChanged) {
|
|
3582
|
+
this.eventEmitter.emitViewChange(state.view, state.date);
|
|
3583
|
+
}
|
|
3584
|
+
if (selectionChanged) {
|
|
3585
|
+
this.eventEmitter.emitSelectionChange(state.selectedEvent);
|
|
3586
|
+
}
|
|
3587
|
+
this.previousView = state.view;
|
|
3588
|
+
this.previousDate = new Date(state.date);
|
|
3589
|
+
this.previousSelectedEventId = selectedEventId;
|
|
3590
|
+
}
|
|
3591
|
+
updateUI(state) {
|
|
3592
|
+
this.updateTitle();
|
|
3593
|
+
// Update view switcher active state
|
|
3594
|
+
const buttons = this.shadowRoot.querySelectorAll('.scheduler-view-switcher button');
|
|
3595
|
+
buttons.forEach((btn) => {
|
|
3596
|
+
const btnEl = btn;
|
|
3597
|
+
btnEl.classList.toggle('active', btnEl.dataset['view'] === state.view);
|
|
3598
|
+
});
|
|
3599
|
+
// Update or re-render view
|
|
3600
|
+
if (this.currentView) {
|
|
3601
|
+
if (this.currentViewType !== state.view) {
|
|
3602
|
+
this.renderView();
|
|
3603
|
+
}
|
|
3604
|
+
else if (state.dragState || state.previewEvent) {
|
|
3605
|
+
this.scheduleDragUpdate(state);
|
|
3606
|
+
}
|
|
3607
|
+
else {
|
|
3608
|
+
this.currentView.update(state);
|
|
3609
|
+
}
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
scheduleDragUpdate(state) {
|
|
3613
|
+
this.latestDragState = state;
|
|
3614
|
+
if (this.pendingDragUpdate !== null) {
|
|
3615
|
+
return;
|
|
3616
|
+
}
|
|
3617
|
+
this.pendingDragUpdate = requestAnimationFrame(() => {
|
|
3618
|
+
this.pendingDragUpdate = null;
|
|
3619
|
+
const stateToApply = this.latestDragState;
|
|
3620
|
+
this.latestDragState = null;
|
|
3621
|
+
if (stateToApply && this.currentView) {
|
|
3622
|
+
this.currentView.update(stateToApply);
|
|
3623
|
+
}
|
|
3624
|
+
});
|
|
3625
|
+
}
|
|
3626
|
+
// ============================================
|
|
3627
|
+
// Input Handling (Callbacks from InputHandler)
|
|
3628
|
+
// ============================================
|
|
3629
|
+
handlePointerDown(pointer, target, immediate) {
|
|
3630
|
+
this.dragManager.handlePointerDown(pointer, target, immediate);
|
|
3631
|
+
}
|
|
3632
|
+
handlePointerMove(pointer) {
|
|
3633
|
+
this.dragManager.handlePointerMove(pointer);
|
|
3634
|
+
}
|
|
3635
|
+
handlePointerUp(pointer) {
|
|
3636
|
+
const result = this.dragManager.handlePointerUp(pointer);
|
|
3637
|
+
if (result) {
|
|
3638
|
+
this.handleDragComplete(result, pointer.originalEvent);
|
|
3639
|
+
}
|
|
3640
|
+
}
|
|
3641
|
+
handleDragComplete(result, originalEvent) {
|
|
3642
|
+
if (result.wasClick) {
|
|
3643
|
+
// It was a click, not a drag
|
|
3644
|
+
if (result.event) {
|
|
3645
|
+
this.stateManager.setSelectedEvent(result.event);
|
|
3646
|
+
this.eventEmitter.emitEventClick(result.event, originalEvent);
|
|
3647
|
+
}
|
|
3648
|
+
return;
|
|
3649
|
+
}
|
|
3650
|
+
// Handle actual drag completion
|
|
3651
|
+
switch (result.type) {
|
|
3652
|
+
case 'create': {
|
|
3653
|
+
const newEvent = {
|
|
3654
|
+
id: generateEventId(),
|
|
3655
|
+
title: 'New Event',
|
|
3656
|
+
start: result.preview.start,
|
|
3657
|
+
end: result.preview.end,
|
|
3658
|
+
color: '#3788d8',
|
|
3659
|
+
};
|
|
3660
|
+
this.stateManager.addEvent(newEvent);
|
|
3661
|
+
this.eventEmitter.emitEventCreate(newEvent, originalEvent);
|
|
3662
|
+
break;
|
|
3663
|
+
}
|
|
3664
|
+
case 'move':
|
|
3665
|
+
case 'resize-start':
|
|
3666
|
+
case 'resize-end': {
|
|
3667
|
+
if (result.event && result.originalEvent) {
|
|
3668
|
+
const updatedEvent = {
|
|
3669
|
+
...result.event,
|
|
3670
|
+
start: result.preview.start,
|
|
3671
|
+
end: result.preview.end,
|
|
3672
|
+
};
|
|
3673
|
+
this.stateManager.updateEvent(updatedEvent);
|
|
3674
|
+
this.eventEmitter.emitEventUpdate(updatedEvent, result.originalEvent, originalEvent);
|
|
3675
|
+
}
|
|
3676
|
+
break;
|
|
3677
|
+
}
|
|
3678
|
+
}
|
|
3679
|
+
}
|
|
3680
|
+
handleClick(pointer, target) {
|
|
3681
|
+
const targetEl = pointer.target;
|
|
3682
|
+
// Group toggle
|
|
3683
|
+
const toggle = targetEl.closest('.expand-toggle');
|
|
3684
|
+
if (toggle) {
|
|
3685
|
+
const groupId = toggle.dataset['groupId'];
|
|
3686
|
+
if (groupId) {
|
|
3687
|
+
this.stateManager.toggleGroupCollapse(groupId);
|
|
3688
|
+
this.renderView();
|
|
3689
|
+
return;
|
|
3690
|
+
}
|
|
3691
|
+
}
|
|
3692
|
+
// Date click
|
|
3693
|
+
const dayEl = targetEl.closest('[data-date]');
|
|
3694
|
+
if (dayEl) {
|
|
3695
|
+
const dateStr = dayEl.dataset['date'];
|
|
3696
|
+
if (dateStr) {
|
|
3697
|
+
this.eventEmitter.emitDateClick(new Date(dateStr), pointer.originalEvent);
|
|
3698
|
+
}
|
|
3699
|
+
}
|
|
3700
|
+
// Month click in year view
|
|
3701
|
+
const monthHeader = targetEl.closest('.scheduler-year-month-header');
|
|
3702
|
+
if (monthHeader) {
|
|
3703
|
+
const monthStr = monthHeader.dataset['month'];
|
|
3704
|
+
if (monthStr) {
|
|
3705
|
+
this.stateManager.setDate(new Date(monthStr));
|
|
3706
|
+
this.stateManager.setView('month');
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
// More link click
|
|
3710
|
+
const moreLink = targetEl.closest('.scheduler-more-link');
|
|
3711
|
+
if (moreLink) {
|
|
3712
|
+
const dateStr = moreLink.dataset['date'];
|
|
3713
|
+
if (dateStr) {
|
|
3714
|
+
this.stateManager.setDate(new Date(dateStr));
|
|
3715
|
+
this.stateManager.setView('day');
|
|
3716
|
+
}
|
|
3717
|
+
}
|
|
3718
|
+
}
|
|
3719
|
+
handleDoubleClick(pointer, target) {
|
|
3720
|
+
if (target.type === 'event' && target.event) {
|
|
3721
|
+
this.eventEmitter.emitEventDblClick(target.event, pointer.originalEvent);
|
|
3722
|
+
}
|
|
3723
|
+
}
|
|
3724
|
+
handleKeyDown(e) {
|
|
3725
|
+
const state = this.stateManager.getState();
|
|
3726
|
+
switch (e.key) {
|
|
3727
|
+
case 'ArrowLeft':
|
|
3728
|
+
this.prev();
|
|
3729
|
+
e.preventDefault();
|
|
3730
|
+
break;
|
|
3731
|
+
case 'ArrowRight':
|
|
3732
|
+
this.next();
|
|
3733
|
+
e.preventDefault();
|
|
3734
|
+
break;
|
|
3735
|
+
case 't':
|
|
3736
|
+
case 'T':
|
|
3737
|
+
this.today();
|
|
3738
|
+
e.preventDefault();
|
|
3739
|
+
break;
|
|
3740
|
+
case 'y':
|
|
3741
|
+
case 'Y':
|
|
3742
|
+
this.changeView('year');
|
|
3743
|
+
e.preventDefault();
|
|
3744
|
+
break;
|
|
3745
|
+
case 'm':
|
|
3746
|
+
case 'M':
|
|
3747
|
+
this.changeView('month');
|
|
3748
|
+
e.preventDefault();
|
|
3749
|
+
break;
|
|
3750
|
+
case 'w':
|
|
3751
|
+
case 'W':
|
|
3752
|
+
this.changeView('week');
|
|
3753
|
+
e.preventDefault();
|
|
3754
|
+
break;
|
|
3755
|
+
case 'd':
|
|
3756
|
+
case 'D':
|
|
3757
|
+
this.changeView('day');
|
|
3758
|
+
e.preventDefault();
|
|
3759
|
+
break;
|
|
3760
|
+
case 'Delete':
|
|
3761
|
+
case 'Backspace':
|
|
3762
|
+
if (state.selectedEvent) {
|
|
3763
|
+
this.eventEmitter.emitEventDelete(state.selectedEvent);
|
|
3764
|
+
}
|
|
3765
|
+
break;
|
|
3766
|
+
case 'Escape':
|
|
3767
|
+
if (this.dragManager.isDragging()) {
|
|
3768
|
+
this.dragManager.cancel();
|
|
3769
|
+
}
|
|
3770
|
+
break;
|
|
3771
|
+
}
|
|
3772
|
+
}
|
|
3773
|
+
// ============================================
|
|
3774
|
+
// Now Indicator Timer
|
|
3775
|
+
// ============================================
|
|
3776
|
+
startNowIndicatorTimer() {
|
|
3777
|
+
// Update every minute (60000ms)
|
|
3778
|
+
this.nowIndicatorTimer = setInterval(() => {
|
|
3779
|
+
this.currentView?.updateNowIndicator();
|
|
3780
|
+
}, 60000);
|
|
3781
|
+
}
|
|
3782
|
+
stopNowIndicatorTimer() {
|
|
3783
|
+
if (this.nowIndicatorTimer !== null) {
|
|
3784
|
+
clearInterval(this.nowIndicatorTimer);
|
|
3785
|
+
this.nowIndicatorTimer = null;
|
|
3786
|
+
}
|
|
3787
|
+
}
|
|
3788
|
+
// ============================================
|
|
3789
|
+
// Slot Resolution
|
|
3790
|
+
// ============================================
|
|
3791
|
+
getSlotAtPosition(clientX, clientY) {
|
|
3792
|
+
const elements = this.shadowRoot.elementsFromPoint(clientX, clientY);
|
|
3793
|
+
const slotEl = elements.find((el) => el.matches('.scheduler-time-slot, .scheduler-timeline-slot'));
|
|
3794
|
+
return slotEl ? this.getSlotFromElement(slotEl) : null;
|
|
3795
|
+
}
|
|
3796
|
+
getSlotFromElement(el) {
|
|
3797
|
+
const startStr = el.dataset['start'];
|
|
3798
|
+
const endStr = el.dataset['end'];
|
|
3799
|
+
if (!startStr || !endStr)
|
|
3800
|
+
return null;
|
|
3801
|
+
return {
|
|
3802
|
+
start: new Date(startStr),
|
|
3803
|
+
end: new Date(endStr),
|
|
3804
|
+
};
|
|
3805
|
+
}
|
|
3806
|
+
}
|
|
3807
|
+
// Register the custom element
|
|
3808
|
+
if (typeof customElements !== 'undefined' && !customElements.get('mp-scheduler')) {
|
|
3809
|
+
customElements.define('mp-scheduler', MpScheduler);
|
|
3810
|
+
}
|
|
3811
|
+
|
|
3812
|
+
// Main component
|
|
3813
|
+
|
|
3814
|
+
/**
|
|
3815
|
+
* Generated bundle index. Do not edit.
|
|
3816
|
+
*/
|
|
3817
|
+
|
|
3818
|
+
export { BaseView, DayView, MonthView, MpScheduler, SchedulerStateManager, TimelineView, WeekView, YearView, createInitialState, schedulerStyles };
|
|
3819
|
+
//# sourceMappingURL=mintplayer-ng-bootstrap-web-components-scheduler.mjs.map
|