@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.
Files changed (182) hide show
  1. package/fesm2022/mintplayer-ng-bootstrap-accordion.mjs +20 -20
  2. package/fesm2022/mintplayer-ng-bootstrap-accordion.mjs.map +1 -1
  3. package/fesm2022/mintplayer-ng-bootstrap-alert.mjs +8 -8
  4. package/fesm2022/mintplayer-ng-bootstrap-alert.mjs.map +1 -1
  5. package/fesm2022/mintplayer-ng-bootstrap-badge.mjs +5 -5
  6. package/fesm2022/mintplayer-ng-bootstrap-badge.mjs.map +1 -1
  7. package/fesm2022/mintplayer-ng-bootstrap-breadcrumb.mjs +6 -6
  8. package/fesm2022/mintplayer-ng-bootstrap-breadcrumb.mjs.map +1 -1
  9. package/fesm2022/mintplayer-ng-bootstrap-button-group.mjs +3 -3
  10. package/fesm2022/mintplayer-ng-bootstrap-button-group.mjs.map +1 -1
  11. package/fesm2022/mintplayer-ng-bootstrap-button-type.mjs +4 -4
  12. package/fesm2022/mintplayer-ng-bootstrap-button-type.mjs.map +1 -1
  13. package/fesm2022/mintplayer-ng-bootstrap-calendar-month.mjs +9 -9
  14. package/fesm2022/mintplayer-ng-bootstrap-calendar-month.mjs.map +1 -1
  15. package/fesm2022/mintplayer-ng-bootstrap-calendar.mjs +10 -10
  16. package/fesm2022/mintplayer-ng-bootstrap-calendar.mjs.map +1 -1
  17. package/fesm2022/mintplayer-ng-bootstrap-card.mjs +8 -8
  18. package/fesm2022/mintplayer-ng-bootstrap-card.mjs.map +1 -1
  19. package/fesm2022/mintplayer-ng-bootstrap-carousel.mjs +25 -25
  20. package/fesm2022/mintplayer-ng-bootstrap-carousel.mjs.map +1 -1
  21. package/fesm2022/mintplayer-ng-bootstrap-close.mjs +3 -3
  22. package/fesm2022/mintplayer-ng-bootstrap-close.mjs.map +1 -1
  23. package/fesm2022/mintplayer-ng-bootstrap-code-snippet.mjs +7 -7
  24. package/fesm2022/mintplayer-ng-bootstrap-code-snippet.mjs.map +1 -1
  25. package/fesm2022/mintplayer-ng-bootstrap-color-picker.mjs +58 -58
  26. package/fesm2022/mintplayer-ng-bootstrap-color-picker.mjs.map +1 -1
  27. package/fesm2022/mintplayer-ng-bootstrap-container.mjs +3 -3
  28. package/fesm2022/mintplayer-ng-bootstrap-container.mjs.map +1 -1
  29. package/fesm2022/mintplayer-ng-bootstrap-context-menu.mjs +3 -3
  30. package/fesm2022/mintplayer-ng-bootstrap-context-menu.mjs.map +1 -1
  31. package/fesm2022/mintplayer-ng-bootstrap-copy.mjs +4 -4
  32. package/fesm2022/mintplayer-ng-bootstrap-copy.mjs.map +1 -1
  33. package/fesm2022/mintplayer-ng-bootstrap-datatable.mjs +20 -20
  34. package/fesm2022/mintplayer-ng-bootstrap-datatable.mjs.map +1 -1
  35. package/fesm2022/mintplayer-ng-bootstrap-datepicker.mjs +6 -6
  36. package/fesm2022/mintplayer-ng-bootstrap-datepicker.mjs.map +1 -1
  37. package/fesm2022/mintplayer-ng-bootstrap-dock.mjs +789 -1175
  38. package/fesm2022/mintplayer-ng-bootstrap-dock.mjs.map +1 -1
  39. package/fesm2022/mintplayer-ng-bootstrap-dropdown-divider.mjs +3 -3
  40. package/fesm2022/mintplayer-ng-bootstrap-dropdown-divider.mjs.map +1 -1
  41. package/fesm2022/mintplayer-ng-bootstrap-dropdown-menu.mjs +10 -10
  42. package/fesm2022/mintplayer-ng-bootstrap-dropdown-menu.mjs.map +1 -1
  43. package/fesm2022/mintplayer-ng-bootstrap-dropdown.mjs +15 -15
  44. package/fesm2022/mintplayer-ng-bootstrap-dropdown.mjs.map +1 -1
  45. package/fesm2022/mintplayer-ng-bootstrap-enhanced-paste.mjs +3 -3
  46. package/fesm2022/mintplayer-ng-bootstrap-enhanced-paste.mjs.map +1 -1
  47. package/fesm2022/mintplayer-ng-bootstrap-enum.mjs +3 -3
  48. package/fesm2022/mintplayer-ng-bootstrap-enum.mjs.map +1 -1
  49. package/fesm2022/mintplayer-ng-bootstrap-file-upload.mjs +16 -16
  50. package/fesm2022/mintplayer-ng-bootstrap-file-upload.mjs.map +1 -1
  51. package/fesm2022/mintplayer-ng-bootstrap-floating-labels.mjs +3 -3
  52. package/fesm2022/mintplayer-ng-bootstrap-floating-labels.mjs.map +1 -1
  53. package/fesm2022/mintplayer-ng-bootstrap-font-color.mjs +3 -3
  54. package/fesm2022/mintplayer-ng-bootstrap-font-color.mjs.map +1 -1
  55. package/fesm2022/mintplayer-ng-bootstrap-for.mjs +4 -4
  56. package/fesm2022/mintplayer-ng-bootstrap-for.mjs.map +1 -1
  57. package/fesm2022/mintplayer-ng-bootstrap-form.mjs +11 -11
  58. package/fesm2022/mintplayer-ng-bootstrap-form.mjs.map +1 -1
  59. package/fesm2022/mintplayer-ng-bootstrap-grid.mjs +26 -26
  60. package/fesm2022/mintplayer-ng-bootstrap-grid.mjs.map +1 -1
  61. package/fesm2022/mintplayer-ng-bootstrap-has-overlay.mjs +4 -4
  62. package/fesm2022/mintplayer-ng-bootstrap-has-overlay.mjs.map +1 -1
  63. package/fesm2022/mintplayer-ng-bootstrap-has-property.mjs +3 -3
  64. package/fesm2022/mintplayer-ng-bootstrap-has-property.mjs.map +1 -1
  65. package/fesm2022/mintplayer-ng-bootstrap-in-list.mjs +3 -3
  66. package/fesm2022/mintplayer-ng-bootstrap-in-list.mjs.map +1 -1
  67. package/fesm2022/mintplayer-ng-bootstrap-input-group.mjs +3 -3
  68. package/fesm2022/mintplayer-ng-bootstrap-input-group.mjs.map +1 -1
  69. package/fesm2022/mintplayer-ng-bootstrap-instance-of.mjs +14 -14
  70. package/fesm2022/mintplayer-ng-bootstrap-instance-of.mjs.map +1 -1
  71. package/fesm2022/mintplayer-ng-bootstrap-let.mjs +4 -4
  72. package/fesm2022/mintplayer-ng-bootstrap-let.mjs.map +1 -1
  73. package/fesm2022/mintplayer-ng-bootstrap-linify.mjs +3 -3
  74. package/fesm2022/mintplayer-ng-bootstrap-linify.mjs.map +1 -1
  75. package/fesm2022/mintplayer-ng-bootstrap-list-group.mjs +7 -7
  76. package/fesm2022/mintplayer-ng-bootstrap-list-group.mjs.map +1 -1
  77. package/fesm2022/mintplayer-ng-bootstrap-markdown.mjs +12 -12
  78. package/fesm2022/mintplayer-ng-bootstrap-markdown.mjs.map +1 -1
  79. package/fesm2022/mintplayer-ng-bootstrap-marquee.mjs +3 -3
  80. package/fesm2022/mintplayer-ng-bootstrap-marquee.mjs.map +1 -1
  81. package/fesm2022/mintplayer-ng-bootstrap-modal.mjs +24 -24
  82. package/fesm2022/mintplayer-ng-bootstrap-modal.mjs.map +1 -1
  83. package/fesm2022/mintplayer-ng-bootstrap-multiselect.mjs +24 -24
  84. package/fesm2022/mintplayer-ng-bootstrap-multiselect.mjs.map +1 -1
  85. package/fesm2022/mintplayer-ng-bootstrap-navbar-toggler.mjs +5 -5
  86. package/fesm2022/mintplayer-ng-bootstrap-navbar-toggler.mjs.map +1 -1
  87. package/fesm2022/mintplayer-ng-bootstrap-navbar.mjs +58 -58
  88. package/fesm2022/mintplayer-ng-bootstrap-navbar.mjs.map +1 -1
  89. package/fesm2022/mintplayer-ng-bootstrap-navigation-lock.mjs +8 -8
  90. package/fesm2022/mintplayer-ng-bootstrap-navigation-lock.mjs.map +1 -1
  91. package/fesm2022/mintplayer-ng-bootstrap-no-noscript.mjs +3 -3
  92. package/fesm2022/mintplayer-ng-bootstrap-no-noscript.mjs.map +1 -1
  93. package/fesm2022/mintplayer-ng-bootstrap-offcanvas.mjs +40 -40
  94. package/fesm2022/mintplayer-ng-bootstrap-offcanvas.mjs.map +1 -1
  95. package/fesm2022/mintplayer-ng-bootstrap-ordinal-number.mjs +3 -3
  96. package/fesm2022/mintplayer-ng-bootstrap-ordinal-number.mjs.map +1 -1
  97. package/fesm2022/mintplayer-ng-bootstrap-pagination.mjs +12 -12
  98. package/fesm2022/mintplayer-ng-bootstrap-pagination.mjs.map +1 -1
  99. package/fesm2022/mintplayer-ng-bootstrap-parallax.mjs +6 -6
  100. package/fesm2022/mintplayer-ng-bootstrap-parallax.mjs.map +1 -1
  101. package/fesm2022/mintplayer-ng-bootstrap-placeholder.mjs +7 -7
  102. package/fesm2022/mintplayer-ng-bootstrap-placeholder.mjs.map +1 -1
  103. package/fesm2022/mintplayer-ng-bootstrap-playlist-toggler.mjs +5 -5
  104. package/fesm2022/mintplayer-ng-bootstrap-playlist-toggler.mjs.map +1 -1
  105. package/fesm2022/mintplayer-ng-bootstrap-popover.mjs +20 -20
  106. package/fesm2022/mintplayer-ng-bootstrap-popover.mjs.map +1 -1
  107. package/fesm2022/mintplayer-ng-bootstrap-priority-nav.mjs +30 -30
  108. package/fesm2022/mintplayer-ng-bootstrap-priority-nav.mjs.map +1 -1
  109. package/fesm2022/mintplayer-ng-bootstrap-progress-bar.mjs +17 -17
  110. package/fesm2022/mintplayer-ng-bootstrap-progress-bar.mjs.map +1 -1
  111. package/fesm2022/mintplayer-ng-bootstrap-range.mjs +9 -9
  112. package/fesm2022/mintplayer-ng-bootstrap-range.mjs.map +1 -1
  113. package/fesm2022/mintplayer-ng-bootstrap-rating.mjs +7 -7
  114. package/fesm2022/mintplayer-ng-bootstrap-rating.mjs.map +1 -1
  115. package/fesm2022/mintplayer-ng-bootstrap-resizable.mjs +25 -25
  116. package/fesm2022/mintplayer-ng-bootstrap-resizable.mjs.map +1 -1
  117. package/fesm2022/mintplayer-ng-bootstrap-scheduler.mjs +16 -16
  118. package/fesm2022/mintplayer-ng-bootstrap-scheduler.mjs.map +1 -1
  119. package/fesm2022/mintplayer-ng-bootstrap-scrollspy.mjs +14 -14
  120. package/fesm2022/mintplayer-ng-bootstrap-scrollspy.mjs.map +1 -1
  121. package/fesm2022/mintplayer-ng-bootstrap-searchbox.mjs +24 -24
  122. package/fesm2022/mintplayer-ng-bootstrap-searchbox.mjs.map +1 -1
  123. package/fesm2022/mintplayer-ng-bootstrap-select.mjs +19 -19
  124. package/fesm2022/mintplayer-ng-bootstrap-select.mjs.map +1 -1
  125. package/fesm2022/mintplayer-ng-bootstrap-select2.mjs +20 -20
  126. package/fesm2022/mintplayer-ng-bootstrap-select2.mjs.map +1 -1
  127. package/fesm2022/mintplayer-ng-bootstrap-shell.mjs +11 -11
  128. package/fesm2022/mintplayer-ng-bootstrap-shell.mjs.map +1 -1
  129. package/fesm2022/mintplayer-ng-bootstrap-signature-pad.mjs +7 -7
  130. package/fesm2022/mintplayer-ng-bootstrap-signature-pad.mjs.map +1 -1
  131. package/fesm2022/mintplayer-ng-bootstrap-slugify.mjs +3 -3
  132. package/fesm2022/mintplayer-ng-bootstrap-slugify.mjs.map +1 -1
  133. package/fesm2022/mintplayer-ng-bootstrap-spinner.mjs +7 -7
  134. package/fesm2022/mintplayer-ng-bootstrap-spinner.mjs.map +1 -1
  135. package/fesm2022/mintplayer-ng-bootstrap-split-string.mjs +3 -3
  136. package/fesm2022/mintplayer-ng-bootstrap-split-string.mjs.map +1 -1
  137. package/fesm2022/mintplayer-ng-bootstrap-sticky-footer.mjs +6 -6
  138. package/fesm2022/mintplayer-ng-bootstrap-sticky-footer.mjs.map +1 -1
  139. package/fesm2022/mintplayer-ng-bootstrap-tab-control.mjs +57 -67
  140. package/fesm2022/mintplayer-ng-bootstrap-tab-control.mjs.map +1 -1
  141. package/fesm2022/mintplayer-ng-bootstrap-table.mjs +10 -10
  142. package/fesm2022/mintplayer-ng-bootstrap-table.mjs.map +1 -1
  143. package/fesm2022/mintplayer-ng-bootstrap-timepicker.mjs +8 -8
  144. package/fesm2022/mintplayer-ng-bootstrap-timepicker.mjs.map +1 -1
  145. package/fesm2022/mintplayer-ng-bootstrap-toast.mjs +24 -24
  146. package/fesm2022/mintplayer-ng-bootstrap-toast.mjs.map +1 -1
  147. package/fesm2022/mintplayer-ng-bootstrap-toggle-button.mjs +22 -22
  148. package/fesm2022/mintplayer-ng-bootstrap-toggle-button.mjs.map +1 -1
  149. package/fesm2022/mintplayer-ng-bootstrap-tooltip.mjs +10 -10
  150. package/fesm2022/mintplayer-ng-bootstrap-tooltip.mjs.map +1 -1
  151. package/fesm2022/mintplayer-ng-bootstrap-treeview.mjs +14 -14
  152. package/fesm2022/mintplayer-ng-bootstrap-treeview.mjs.map +1 -1
  153. package/fesm2022/mintplayer-ng-bootstrap-trust-html.mjs +3 -3
  154. package/fesm2022/mintplayer-ng-bootstrap-trust-html.mjs.map +1 -1
  155. package/fesm2022/mintplayer-ng-bootstrap-typeahead.mjs +10 -10
  156. package/fesm2022/mintplayer-ng-bootstrap-typeahead.mjs.map +1 -1
  157. package/fesm2022/mintplayer-ng-bootstrap-uc-first.mjs +3 -3
  158. package/fesm2022/mintplayer-ng-bootstrap-uc-first.mjs.map +1 -1
  159. package/fesm2022/mintplayer-ng-bootstrap-user-agent.mjs +3 -3
  160. package/fesm2022/mintplayer-ng-bootstrap-user-agent.mjs.map +1 -1
  161. package/fesm2022/mintplayer-ng-bootstrap-viewport.mjs +3 -3
  162. package/fesm2022/mintplayer-ng-bootstrap-viewport.mjs.map +1 -1
  163. package/fesm2022/mintplayer-ng-bootstrap-virtual-datatable.mjs +10 -10
  164. package/fesm2022/mintplayer-ng-bootstrap-virtual-datatable.mjs.map +1 -1
  165. package/fesm2022/mintplayer-ng-bootstrap-web-components-scheduler-core.mjs +1356 -0
  166. package/fesm2022/mintplayer-ng-bootstrap-web-components-scheduler-core.mjs.map +1 -0
  167. package/fesm2022/mintplayer-ng-bootstrap-web-components-scheduler.mjs +3819 -0
  168. package/fesm2022/mintplayer-ng-bootstrap-web-components-scheduler.mjs.map +1 -0
  169. package/fesm2022/mintplayer-ng-bootstrap-web-components-splitter.mjs +731 -0
  170. package/fesm2022/mintplayer-ng-bootstrap-web-components-splitter.mjs.map +1 -0
  171. package/fesm2022/mintplayer-ng-bootstrap-web-components-tab-control.mjs +549 -0
  172. package/fesm2022/mintplayer-ng-bootstrap-web-components-tab-control.mjs.map +1 -0
  173. package/fesm2022/mintplayer-ng-bootstrap-word-count.mjs +3 -3
  174. package/fesm2022/mintplayer-ng-bootstrap-word-count.mjs.map +1 -1
  175. package/package.json +20 -6
  176. package/types/mintplayer-ng-bootstrap-dock.d.ts +55 -19
  177. package/types/mintplayer-ng-bootstrap-scheduler.d.ts +2 -2
  178. package/types/mintplayer-ng-bootstrap-tab-control.d.ts +7 -11
  179. package/types/mintplayer-ng-bootstrap-web-components-scheduler-core.d.ts +890 -0
  180. package/types/mintplayer-ng-bootstrap-web-components-scheduler.d.ts +354 -0
  181. package/types/mintplayer-ng-bootstrap-web-components-splitter.d.ts +165 -0
  182. 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