x4js 2.3.28 → 2.3.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/aicontext.md +380 -19
- package/package.json +7 -3
- package/src/components/bargraph/bargraph.module.scss +44 -0
- package/src/components/bargraph/bargraph.ts +362 -0
- package/src/components/base.scss +71 -1
- package/src/components/calendar/calendar.module.scss +6 -5
- package/src/components/calendar/calendar.ts +14 -8
- package/src/components/canvas/canvas.ts +5 -4
- package/src/components/carousel/carousel.module.scss +165 -0
- package/src/components/carousel/carousel.ts +423 -0
- package/src/components/chart/chart.module.scss +8 -0
- package/src/components/chart/chart.ts +170 -12
- package/src/components/combobox/combobox.module.scss +2 -1
- package/src/components/components.ts +3 -0
- package/src/components/gantt/gantt.module.scss +462 -0
- package/src/components/gantt/gantt.ts +1254 -0
- package/src/components/knob/knob.module.scss +113 -0
- package/src/components/knob/knob.ts +355 -0
- package/src/components/mapview/mapview.module.scss +177 -0
- package/src/components/mapview/mapview.ts +764 -0
- package/src/components/planner/planner.module.scss +688 -0
- package/src/components/planner/planner.ts +1792 -0
- package/src/components/radio/radio.module.scss +4 -1
- package/src/components/slider/slider.module.scss +22 -20
- package/src/components/switch/switch.module.scss +68 -44
- package/src/components/switch/switch.ts +6 -3
- package/src/components/tag/tag.module.scss +4 -4
- package/src/components/textedit/textedit.module.scss +5 -2
- package/src/components/themes.scss +19 -6
- package/src/core/core_i18n.ts +65 -0
- package/src/core/core_tools.ts +44 -0
- package/src/x4doc.ts +3 -0
|
@@ -0,0 +1,1792 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ___ ___ __
|
|
3
|
+
* \ \/ / / _
|
|
4
|
+
* \ / /_| |_
|
|
5
|
+
* / \____ _|
|
|
6
|
+
* /__/\__\ |_|
|
|
7
|
+
*
|
|
8
|
+
* @file planner.ts
|
|
9
|
+
* @author Etienne Cochard
|
|
10
|
+
*
|
|
11
|
+
* @copyright (c) 2026 R-libre ingenierie
|
|
12
|
+
*
|
|
13
|
+
* Use of this source code is governed by an MIT-style license
|
|
14
|
+
* that can be found in the LICENSE file or at https://opensource.org/licenses/MIT.
|
|
15
|
+
**/
|
|
16
|
+
|
|
17
|
+
import { Component, ComponentEvent, ComponentEvents, ComponentProps, EvClick, EvContextMenu, EvDblClick, EvSelectionChange } from '../../core/component';
|
|
18
|
+
import { EventCallback } from '../../core/core_events';
|
|
19
|
+
import { _tr } from '../../core/core_i18n';
|
|
20
|
+
import { isMac } from '../../core/core_shortcuts';
|
|
21
|
+
import { SvgBuilder, SvgComponent, SvgPath } from '../../core/core_svg';
|
|
22
|
+
import { clamp, date_add_days, date_clone, date_format, date_hash, date_week_start, formatIntlDate } from '../../core/core_tools';
|
|
23
|
+
import { Icon } from '../icon/icon';
|
|
24
|
+
|
|
25
|
+
import "./planner.module.scss";
|
|
26
|
+
|
|
27
|
+
export type PlannerID = string | number;
|
|
28
|
+
export type PlannerView = "day" | "week" | "month";
|
|
29
|
+
|
|
30
|
+
export interface PlannerEvent {
|
|
31
|
+
id: PlannerID;
|
|
32
|
+
title: string;
|
|
33
|
+
start: Date;
|
|
34
|
+
end?: Date; // a date without hours is a whole day: end is its last day, included; else the time it ends. none: the day of start, or an hour after it
|
|
35
|
+
text?: string; // under the title, on as many lines as it has room for
|
|
36
|
+
color?: string; // a CSS colour for its border, its icon and a tint of its background; none: the accent of the theme
|
|
37
|
+
icon?: string; // on the right of its title, in its colour (as Icon: an svg)
|
|
38
|
+
tooltip?: string; // as html (see initTooltips)
|
|
39
|
+
cls?: string; // on each part of it: in each day, in the band, in the month
|
|
40
|
+
data?: any;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface EvPlannerChange extends ComponentEvent {
|
|
44
|
+
readonly event: PlannerEvent; // as it was: the new dates are written in it after the event, unless ev.preventDefault( )
|
|
45
|
+
readonly start: Date;
|
|
46
|
+
readonly end: Date; // as PlannerEvent.end
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface EvPlannerSelect extends ComponentEvent {
|
|
50
|
+
readonly start: Date; // as an event: whole days have no hours, end is the last one
|
|
51
|
+
readonly end: Date;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface EvPlannerView extends ComponentEvent {
|
|
55
|
+
readonly view: PlannerView;
|
|
56
|
+
readonly date: Date;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface PlannerEvents extends ComponentEvents {
|
|
60
|
+
click: EvClick; // context: the event
|
|
61
|
+
dblClick: EvDblClick; // context: the event
|
|
62
|
+
contextMenu: EvContextMenu; // context: the event
|
|
63
|
+
selectionChange: EvSelectionChange;
|
|
64
|
+
change: EvPlannerChange; // an event moved, or one of its ends
|
|
65
|
+
copy: EvPlannerChange; // an event copied (ctrl, option on a Mac): nothing is written, the application adds it
|
|
66
|
+
select: EvPlannerSelect; // a range of time or of days, to create an event
|
|
67
|
+
viewChange: EvPlannerView; // the planner took another view: a day of the month opened
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface PlannerProps extends Omit<ComponentProps,"content"> {
|
|
71
|
+
events?: PlannerEvent[];
|
|
72
|
+
view?: PlannerView; // "week" by default
|
|
73
|
+
date?: Date; // a day shown; today by default
|
|
74
|
+
workDays?: number[]; // as Date.getDay( ): monday to friday by default
|
|
75
|
+
workHours?: [number, number]; // in minutes from midnight: 8:00 to 18:00 by default
|
|
76
|
+
showOffDays?: boolean; // the other days, narrower: true by default; false: the working days alone
|
|
77
|
+
showOffHours?: boolean; // the other hours, above and under the working ones, reached by the scroll: true by default; false: the working hours alone
|
|
78
|
+
band?: boolean; // the events of whole days in a band under the days: true by default; false: in the working hours of their days
|
|
79
|
+
showSubGrid?: boolean; // the lines of the steps between the hours: false by default
|
|
80
|
+
step?: number; // the precision of the pointer, in minutes: 15 by default
|
|
81
|
+
readonly?: boolean; // nothing is created, moved, stretched nor copied; the events are still clicked, the view moved
|
|
82
|
+
click?: EventCallback<EvClick>;
|
|
83
|
+
dblClick?: EventCallback<EvDblClick>;
|
|
84
|
+
contextMenu?: EventCallback<EvContextMenu>;
|
|
85
|
+
selectionChange?: EventCallback<EvSelectionChange>;
|
|
86
|
+
change?: EventCallback<EvPlannerChange>;
|
|
87
|
+
copy?: EventCallback<EvPlannerChange>;
|
|
88
|
+
select?: EventCallback<EvPlannerSelect>;
|
|
89
|
+
viewChange?: EventCallback<EvPlannerView>;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// an event on the planner: [start, end[, whole days from the midnight of the first one to the one after the last
|
|
93
|
+
interface PlannerItem {
|
|
94
|
+
event: PlannerEvent;
|
|
95
|
+
start: Date;
|
|
96
|
+
end: Date;
|
|
97
|
+
allDay: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// a day of the week, its place
|
|
101
|
+
interface PlannerColumn {
|
|
102
|
+
date: Date; // at midnight
|
|
103
|
+
work: boolean;
|
|
104
|
+
x: number;
|
|
105
|
+
w: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// the part of an event with hours on a day, in minutes from midnight
|
|
109
|
+
interface PlannerSegment {
|
|
110
|
+
item: PlannerItem;
|
|
111
|
+
from: number;
|
|
112
|
+
to: number;
|
|
113
|
+
first: boolean; // the day it starts
|
|
114
|
+
last: boolean; // the day it ends
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// an event of whole days in the band: its columns, its line
|
|
118
|
+
interface PlannerBar {
|
|
119
|
+
item: PlannerItem;
|
|
120
|
+
c0: number;
|
|
121
|
+
c1: number;
|
|
122
|
+
lane: number;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// a day of the month
|
|
126
|
+
interface PlannerDay {
|
|
127
|
+
date: Date;
|
|
128
|
+
cell: Component;
|
|
129
|
+
list: Component;
|
|
130
|
+
more: Component;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// a point of the planner: the day, and the minutes in the canvas
|
|
134
|
+
interface PlannerPoint {
|
|
135
|
+
day: Date;
|
|
136
|
+
t: number;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface PlannerDrag {
|
|
140
|
+
area: "canvas" | "band" | "month";
|
|
141
|
+
kind: "select" | "move" | "start" | "end";
|
|
142
|
+
pointer: number;
|
|
143
|
+
x: number; // where the pointer went down, in the window
|
|
144
|
+
y: number;
|
|
145
|
+
moved: boolean; // the pointer went 4px away: a drag, not a click
|
|
146
|
+
copy: boolean; // the key of the copy is down
|
|
147
|
+
item: PlannerItem; // move, start, end
|
|
148
|
+
block: Component; // the part of it under the pointer: stretched by one of its ends
|
|
149
|
+
blocks: Component[]; // all its parts: faded while it is moved, not copied
|
|
150
|
+
ghost: Component; // where it goes, while it is moved
|
|
151
|
+
day: Date; // the day under the pointer at the start
|
|
152
|
+
t0: number; // canvas: the minutes under the pointer at the start
|
|
153
|
+
preview: { start: Date, end: Date }; // the event where it goes
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// the range selected, [start, end[: on a day of the canvas, or days of the band or of the month
|
|
157
|
+
interface PlannerRange {
|
|
158
|
+
area: PlannerDrag["area"];
|
|
159
|
+
start: Date;
|
|
160
|
+
end: Date;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const DAY = 86400000;
|
|
164
|
+
const DAY_MINUTES = 24 * 60;
|
|
165
|
+
const GUTTER = 52; // the hours, on the left
|
|
166
|
+
const MIN_STEP_PX = 10; // a step of the working hours, at least
|
|
167
|
+
const HOURS_MARGIN = 12; // shown above and under the working hours: their first and last hours can be read
|
|
168
|
+
const OFF_DAY_WEIGHT = 0.35; // the width of a day off in the week, a working day being 1
|
|
169
|
+
const OFF_DAY_WEIGHT_MONTH = 0.5;
|
|
170
|
+
const FREE_STRIP = 14; // on the right of a day: a press there selects, even over an event
|
|
171
|
+
const SMALL_EVENT = 40; // under this height, an event is on one line
|
|
172
|
+
const EVENT_GAP = 2; // between an event and the next one, from top to bottom
|
|
173
|
+
const GHOST_SHIFT = 16; // the ghost of an event alone in its day: not on the event alone in the other one
|
|
174
|
+
const BAND_LINE = 22; // a line of the band of the whole days
|
|
175
|
+
const MONTH_LINE = 20; // a line of a day of the month, its gap included (scss)
|
|
176
|
+
const SCROLL_EDGE = 24; // a drag that near the top or the bottom of the body scrolls it
|
|
177
|
+
const SCROLL_STEP = 8; // by so many px every 20ms
|
|
178
|
+
|
|
179
|
+
// the days of the week from monday, as Date.getDay( ) numbers them
|
|
180
|
+
const WEEK = [ 1, 2, 3, 4, 5, 6, 0 ];
|
|
181
|
+
|
|
182
|
+
function hasTime( date: Date ) {
|
|
183
|
+
return date.getHours( )!=0 || date.getMinutes( )!=0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function dayOf( date: Date ) {
|
|
187
|
+
return new Date( date.getFullYear( ), date.getMonth( ), date.getDate( ) );
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function minutesOf( date: Date ) {
|
|
191
|
+
return date.getHours( ) * 60 + date.getMinutes( );
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// a time of a day; 24:00: the midnight after it
|
|
195
|
+
function atMinutes( day: Date, minutes: number ) {
|
|
196
|
+
return new Date( day.getFullYear( ), day.getMonth( ), day.getDate( ), 0, minutes );
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// the days from a midnight to another one (a day of summer time counts as one)
|
|
200
|
+
function daysBetween( from: Date, to: Date ) {
|
|
201
|
+
return Math.round( ( to.getTime( ) - from.getTime( ) ) / DAY );
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function timeText( date: Date ) {
|
|
205
|
+
return formatIntlDate( date, "h:I" );
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* an event on the planner: [start, end[
|
|
210
|
+
*/
|
|
211
|
+
|
|
212
|
+
function measure( event: PlannerEvent ): PlannerItem {
|
|
213
|
+
const start = event.start;
|
|
214
|
+
|
|
215
|
+
if( !hasTime( start ) && ( !event.end || !hasTime( event.end ) ) ) {
|
|
216
|
+
const last = event.end && event.end.getTime( ) > start.getTime( ) ? event.end : start;
|
|
217
|
+
return { event, start: dayOf( start ), end: date_add_days( dayOf( last ), 1 ), allDay: true };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const end = event.end ?? new Date( start.getTime( ) + 3600000 );
|
|
221
|
+
return { event, start, end: end.getTime( ) > start.getTime( ) ? end : start, allDay: false };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* the end of an event from [start, end[: the day before for whole days (their last one), else that time
|
|
226
|
+
*/
|
|
227
|
+
|
|
228
|
+
function endOf( start: Date, end: Date ) {
|
|
229
|
+
return !hasTime( start ) && !hasTime( end ) ? date_add_days( end, -1 ) : end;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* the lane of each segment in its group of segments that overlap, and the count of lanes of that
|
|
234
|
+
* group: they share the width of their day. segments: sorted by start
|
|
235
|
+
*/
|
|
236
|
+
|
|
237
|
+
function layoutLanes( segments: PlannerSegment[] ) {
|
|
238
|
+
const result: { segment: PlannerSegment, lane: number, lanes: number }[] = [];
|
|
239
|
+
|
|
240
|
+
let group: typeof result = [];
|
|
241
|
+
let ends: number[] = [];
|
|
242
|
+
let groupEnd = -1;
|
|
243
|
+
|
|
244
|
+
const close = ( ) => {
|
|
245
|
+
group.forEach( r => r.lanes = ends.length );
|
|
246
|
+
result.push( ...group );
|
|
247
|
+
group = [];
|
|
248
|
+
ends = [];
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
segments.forEach( segment => {
|
|
252
|
+
if( segment.from >= groupEnd ) {
|
|
253
|
+
close( );
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let lane = ends.findIndex( end => end <= segment.from );
|
|
257
|
+
if( lane < 0 ) {
|
|
258
|
+
lane = ends.length;
|
|
259
|
+
ends.push( segment.to );
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
ends[lane] = segment.to;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
group.push( { segment, lane, lanes: 0 } );
|
|
266
|
+
groupEnd = Math.max( groupEnd, segment.to );
|
|
267
|
+
} );
|
|
268
|
+
|
|
269
|
+
close( );
|
|
270
|
+
return result;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* the first and the last index of the dates from a day to another, [from, to[; null: none of them
|
|
275
|
+
*/
|
|
276
|
+
|
|
277
|
+
function indicesOf( dates: Date[], from: Date, to: Date ): [ number, number ] {
|
|
278
|
+
let first = -1;
|
|
279
|
+
let last = -1;
|
|
280
|
+
|
|
281
|
+
dates.forEach( ( date, i ) => {
|
|
282
|
+
if( date.getTime( ) >= from.getTime( ) && date.getTime( ) < to.getTime( ) ) {
|
|
283
|
+
if( first < 0 ) {
|
|
284
|
+
first = i;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
last = i;
|
|
288
|
+
}
|
|
289
|
+
} );
|
|
290
|
+
|
|
291
|
+
return first < 0 ? null : [ first, last ];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// the key that makes a drag a copy: option on a Mac, where ctrl+click is the right click; ctrl elsewhere
|
|
295
|
+
function isCopyKey( ev: MouseEvent ) {
|
|
296
|
+
return isMac ? ev.altKey : ev.ctrlKey;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Not in `x4js`: import it from `"x4js/planner"`.
|
|
301
|
+
*
|
|
302
|
+
* Events on a calendar, by day, by week or by month.
|
|
303
|
+
*
|
|
304
|
+
* In a day or a week, the days are in columns, the hours from top to bottom: the working hours
|
|
305
|
+
* fill the height of the view, the others above and under them are reached by the scroll, or not
|
|
306
|
+
* shown (showOffHours); the days
|
|
307
|
+
* off are narrower, or not shown (showOffDays). The events of whole days are in a band under the
|
|
308
|
+
* days, or in the working hours of their days (band). The lines of the steps between the hours
|
|
309
|
+
* can be shown (showSubGrid).
|
|
310
|
+
*
|
|
311
|
+
* With the pointer: a drag on an empty place selects a range (`select`: the application creates
|
|
312
|
+
* the event); the strip on the right of a day, or shift, select over the events too. A drag moves
|
|
313
|
+
* an event, or one of its ends (`change`); ctrl (option on a Mac) copies it (`copy`). In the month,
|
|
314
|
+
* a double click on a day, or its "+n", opens its week.
|
|
315
|
+
*
|
|
316
|
+
* Read only (readonly, setReadOnly): nothing is created, moved, stretched nor copied. Disabled
|
|
317
|
+
* (disabled, enable( false )): nothing, faded as a Box (--box-disabled-opacity).
|
|
318
|
+
*
|
|
319
|
+
* A date without hours is a whole day: `end` is the last day of the event, included.
|
|
320
|
+
*
|
|
321
|
+
* @cssvar
|
|
322
|
+
* ```
|
|
323
|
+
* --planner-background
|
|
324
|
+
* --planner-border
|
|
325
|
+
* --planner-border-focus
|
|
326
|
+
* --planner-header-color
|
|
327
|
+
* --planner-day-color
|
|
328
|
+
* --planner-off-color
|
|
329
|
+
* --planner-today-background
|
|
330
|
+
* --planner-today-color
|
|
331
|
+
* --planner-today-column
|
|
332
|
+
* --planner-off-background
|
|
333
|
+
* --planner-line-color
|
|
334
|
+
* --planner-step-color
|
|
335
|
+
* --planner-hour-line-color
|
|
336
|
+
* --planner-hour-color
|
|
337
|
+
* --planner-hour-color-work
|
|
338
|
+
* --planner-edge-background
|
|
339
|
+
* --planner-edge-color
|
|
340
|
+
* --planner-edge-shadow
|
|
341
|
+
* --planner-edge-opacity
|
|
342
|
+
* --planner-now-color
|
|
343
|
+
* --planner-now-tag
|
|
344
|
+
* --planner-now-text
|
|
345
|
+
* --planner-event-background
|
|
346
|
+
* --planner-event-color
|
|
347
|
+
* --planner-event-tint
|
|
348
|
+
* --planner-event-radius
|
|
349
|
+
* --planner-event-title
|
|
350
|
+
* --planner-event-text
|
|
351
|
+
* --planner-event-selected
|
|
352
|
+
* --planner-range-background
|
|
353
|
+
* --planner-range-border
|
|
354
|
+
* --planner-out-color
|
|
355
|
+
* --planner-more-color
|
|
356
|
+
* ```
|
|
357
|
+
*/
|
|
358
|
+
|
|
359
|
+
export class Planner extends Component<PlannerProps, PlannerEvents> {
|
|
360
|
+
static classPrefix = "x4";
|
|
361
|
+
|
|
362
|
+
private _events: PlannerEvent[];
|
|
363
|
+
private _items: PlannerItem[] = [];
|
|
364
|
+
private _view: PlannerView;
|
|
365
|
+
private _date: Date; // at midnight
|
|
366
|
+
private _workDays: number[];
|
|
367
|
+
private _workHours: [number, number];
|
|
368
|
+
private _offDays: boolean; // the days off shown
|
|
369
|
+
private _offHours: boolean; // the hours out of the working ones shown
|
|
370
|
+
private _bandShown: boolean; // the events of whole days in the band
|
|
371
|
+
private _readonly: boolean;
|
|
372
|
+
private _step: number;
|
|
373
|
+
|
|
374
|
+
private _columns: PlannerColumn[] = [];
|
|
375
|
+
private _monthDays: PlannerDay[] = [];
|
|
376
|
+
private _blocks: Component[] = []; // the events drawn: in the days, in the band, in the month
|
|
377
|
+
private _hourPx = 40; // the height of a working hour
|
|
378
|
+
private _scrolled = false; // the first layout shows the start of the working hours
|
|
379
|
+
private _selection: PlannerID;
|
|
380
|
+
private _drag: PlannerDrag;
|
|
381
|
+
private _range: PlannerRange;
|
|
382
|
+
private _scrollDir = 0; // a drag near the top (-1) or the bottom (1) of the body: it scrolls
|
|
383
|
+
private _scrollEv: PointerEvent; // the last move of that drag
|
|
384
|
+
private _parts: { col: PlannerColumn, top: number, bottom: number }[] = []; // the events in the days: where they are
|
|
385
|
+
|
|
386
|
+
private _head: Component;
|
|
387
|
+
private _band: Component;
|
|
388
|
+
private _bandRange: Component;
|
|
389
|
+
private _body: Component;
|
|
390
|
+
private _canvas: Component;
|
|
391
|
+
private _grid: SvgComponent;
|
|
392
|
+
private _hours: Component;
|
|
393
|
+
private _layer: Component;
|
|
394
|
+
private _marks: Component;
|
|
395
|
+
private _now: Component; // the line of now: on the days before today
|
|
396
|
+
private _nowToday: Component; // on today, paler
|
|
397
|
+
private _nowAfter: Component; // on the days after it
|
|
398
|
+
private _nowTime: Component;
|
|
399
|
+
private _edges: Component;
|
|
400
|
+
private _month: Component;
|
|
401
|
+
private _names: Component;
|
|
402
|
+
private _cells: Component;
|
|
403
|
+
|
|
404
|
+
constructor( props: PlannerProps ) {
|
|
405
|
+
super( props );
|
|
406
|
+
|
|
407
|
+
this._events = props.events ?? [];
|
|
408
|
+
this._view = props.view ?? "week";
|
|
409
|
+
this._date = dayOf( props.date ?? new Date( ) );
|
|
410
|
+
this._workDays = props.workDays ?? [ 1, 2, 3, 4, 5 ];
|
|
411
|
+
this._workHours = props.workHours ?? [ 8 * 60, 18 * 60 ];
|
|
412
|
+
this._offDays = props.showOffDays ?? true;
|
|
413
|
+
this._offHours = props.showOffHours ?? true;
|
|
414
|
+
this._bandShown = props.band ?? true;
|
|
415
|
+
this._step = props.step ?? 15;
|
|
416
|
+
|
|
417
|
+
this.setAttribute( "tabindex", 0 );
|
|
418
|
+
this._readonly = !!props.readonly;
|
|
419
|
+
this.setClass( "readonly", this._readonly );
|
|
420
|
+
this.setClass( "nosubgrid", !props.showSubGrid );
|
|
421
|
+
|
|
422
|
+
this._bandRange = new Component( { cls: "range" } );
|
|
423
|
+
|
|
424
|
+
this.setContent( [
|
|
425
|
+
this._head = new Component( { cls: "head" } ),
|
|
426
|
+
this._band = new Component( { cls: "band" } ),
|
|
427
|
+
this._body = new Component( { cls: "body", content:
|
|
428
|
+
this._canvas = new Component( { cls: "canvas", content: [
|
|
429
|
+
this._grid = new SvgComponent( { cls: "grid" } ),
|
|
430
|
+
this._hours = new Component( { cls: "hours" } ),
|
|
431
|
+
this._layer = new Component( { cls: "layer" } ),
|
|
432
|
+
this._marks = new Component( { cls: "marks" } ),
|
|
433
|
+
this._now = new Component( { cls: "now" } ),
|
|
434
|
+
this._nowToday = new Component( { cls: "now today" } ),
|
|
435
|
+
this._nowAfter = new Component( { cls: "now" } ),
|
|
436
|
+
this._nowTime = new Component( { cls: "now-time" } ),
|
|
437
|
+
this._edges = new Component( { cls: "edges" } ),
|
|
438
|
+
] } ),
|
|
439
|
+
} ),
|
|
440
|
+
this._month = new Component( { cls: "month", content: [
|
|
441
|
+
this._names = new Component( { cls: "names" } ),
|
|
442
|
+
this._cells = new Component( { cls: "cells" } ),
|
|
443
|
+
] } ),
|
|
444
|
+
] );
|
|
445
|
+
|
|
446
|
+
this.mapPropEvents( props, "click", "dblClick", "contextMenu", "selectionChange", "change", "copy", "select", "viewChange" );
|
|
447
|
+
|
|
448
|
+
this.addDOMEvent( "dblclick", ( ev ) => this._onDblClick( ev ) );
|
|
449
|
+
this.addDOMEvent( "contextmenu", ( ev ) => this._onContextMenu( ev ) );
|
|
450
|
+
this.addDOMEvent( "keydown", ( ev ) => this._onKey( ev ) );
|
|
451
|
+
this.addDOMEvent( "resized", ( ) => this._onResize( ) );
|
|
452
|
+
this._body.addDOMEvent( "scroll", ( ) => this._renderEdges( ) );
|
|
453
|
+
|
|
454
|
+
this.addDOMEvent( "pointerdown", ( ev ) => this._onPointerDown( ev ) );
|
|
455
|
+
this.addDOMEvent( "pointermove", ( ev ) => this._onPointerMove( ev ) );
|
|
456
|
+
this.addDOMEvent( "pointerup", ( ev ) => this._onPointerUp( ev ) );
|
|
457
|
+
this.addDOMEvent( "pointercancel", ( ) => this._onPointerCancel( ) );
|
|
458
|
+
this.addDOMEvent( "lostpointercapture", ( ) => this._onPointerCancel( ) );
|
|
459
|
+
|
|
460
|
+
this.setInterval( "now", 60 * 1000, ( ) => this._placeNow( ) );
|
|
461
|
+
|
|
462
|
+
this._render( );
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* the event selected, when it is not in them any more: none
|
|
467
|
+
*/
|
|
468
|
+
|
|
469
|
+
setEvents( events: PlannerEvent[] ) {
|
|
470
|
+
this._events = events;
|
|
471
|
+
this._range = null;
|
|
472
|
+
|
|
473
|
+
if( !events.some( event => event.id===this._selection ) ) {
|
|
474
|
+
this._selection = undefined;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
this._render( );
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
setView( view: PlannerView ) {
|
|
481
|
+
this._view = view;
|
|
482
|
+
this._range = null;
|
|
483
|
+
this._render( );
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
getView( ): PlannerView {
|
|
487
|
+
return this._view;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* nothing is created, moved, stretched nor copied; the events are still clicked, the view moved
|
|
492
|
+
*/
|
|
493
|
+
|
|
494
|
+
setReadOnly( ro: boolean ) {
|
|
495
|
+
this._readonly = ro;
|
|
496
|
+
this.setClass( "readonly", ro );
|
|
497
|
+
this._renderItems( ); // their ends to drag
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* the events of whole days in a band under the days; false: in the working hours of their days
|
|
502
|
+
*/
|
|
503
|
+
|
|
504
|
+
setBand( band: boolean ) {
|
|
505
|
+
this._bandShown = band;
|
|
506
|
+
this._range = null;
|
|
507
|
+
this._render( );
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* the days off shown, narrower; false: the working days alone
|
|
512
|
+
*/
|
|
513
|
+
|
|
514
|
+
setShowOffDays( show: boolean ) {
|
|
515
|
+
this._offDays = show;
|
|
516
|
+
this._range = null;
|
|
517
|
+
this._render( );
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* the lines of the steps between the hours; false: the lines of the hours alone
|
|
522
|
+
*/
|
|
523
|
+
|
|
524
|
+
setShowSubGrid( show: boolean ) {
|
|
525
|
+
this.setClass( "nosubgrid", !show );
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* the precision of the pointer, in minutes; its lines in the working hours, of 10px at least
|
|
530
|
+
*/
|
|
531
|
+
|
|
532
|
+
setStep( step: number ) {
|
|
533
|
+
this._step = step;
|
|
534
|
+
this._range = null;
|
|
535
|
+
this._render( );
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* the hours out of the working ones shown, reached by the scroll; false: the working hours alone
|
|
540
|
+
*/
|
|
541
|
+
|
|
542
|
+
setShowOffHours( show: boolean ) {
|
|
543
|
+
this._offHours = show;
|
|
544
|
+
this._scrolled = false;
|
|
545
|
+
this._range = null;
|
|
546
|
+
this._render( );
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* a day to show: its day, its week or its month
|
|
551
|
+
*/
|
|
552
|
+
|
|
553
|
+
setDate( date: Date ) {
|
|
554
|
+
this._date = dayOf( date );
|
|
555
|
+
this._range = null;
|
|
556
|
+
this._render( );
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
getDate( ): Date {
|
|
560
|
+
return date_clone( this._date );
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* the day, the week or the month after
|
|
565
|
+
*/
|
|
566
|
+
|
|
567
|
+
next( ) {
|
|
568
|
+
this._moveBy( 1 );
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
prev( ) {
|
|
572
|
+
this._moveBy( -1 );
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* the first and the last day shown; in the month, its days
|
|
577
|
+
*/
|
|
578
|
+
|
|
579
|
+
getRange( ): { start: Date, end: Date } {
|
|
580
|
+
const date = this._date;
|
|
581
|
+
|
|
582
|
+
switch( this._view ) {
|
|
583
|
+
case "day": {
|
|
584
|
+
return { start: date_clone( date ), end: date_clone( date ) };
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
case "week": {
|
|
588
|
+
const start = date_week_start( date );
|
|
589
|
+
return { start, end: date_add_days( start, 6 ) };
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
default: {
|
|
593
|
+
return { start: new Date( date.getFullYear( ), date.getMonth( ), 1 ), end: new Date( date.getFullYear( ), date.getMonth( ) + 1, 0 ) };
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* an event selected, without selectionChange; null: none
|
|
600
|
+
*/
|
|
601
|
+
|
|
602
|
+
selectEvent( id: PlannerID ) {
|
|
603
|
+
const item = this._items.find( item => item.event.id===id );
|
|
604
|
+
this._select( item ?? null, false );
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
getSelection( ): PlannerID {
|
|
608
|
+
return this._selection;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
private _moveBy( n: number ) {
|
|
612
|
+
const date = this._date;
|
|
613
|
+
|
|
614
|
+
switch( this._view ) {
|
|
615
|
+
case "day": {
|
|
616
|
+
// the days off not shown: the working day before or after
|
|
617
|
+
let day = date_add_days( date, n );
|
|
618
|
+
while( !this._offDays && this._workDays.length && !this._isWorkDay( day ) ) {
|
|
619
|
+
day = date_add_days( day, n );
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
this.setDate( day );
|
|
623
|
+
break;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
case "week": this.setDate( date_add_days( date, 7 * n ) ); break;
|
|
627
|
+
case "month": this.setDate( new Date( date.getFullYear( ), date.getMonth( ) + n, 1 ) ); break;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// :: RENDER ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
632
|
+
|
|
633
|
+
private _render( ) {
|
|
634
|
+
this._items = this._events.filter( event => !!event.start ).map( measure );
|
|
635
|
+
this.setClass( "month", this._view=="month" );
|
|
636
|
+
this.setClass( "day-view", this._view=="day" );
|
|
637
|
+
|
|
638
|
+
if( this._view=="month" ) {
|
|
639
|
+
this._renderMonth( );
|
|
640
|
+
}
|
|
641
|
+
else {
|
|
642
|
+
this._renderWeek( );
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* the days in columns (the days off too, or not), the hours from top to bottom: the working
|
|
648
|
+
* hours fill what remains of the height, the others as high (or not shown), a step never
|
|
649
|
+
* smaller than MIN_STEP_PX
|
|
650
|
+
*/
|
|
651
|
+
|
|
652
|
+
private _renderWeek( ) {
|
|
653
|
+
const day = this._view=="day";
|
|
654
|
+
const first = day ? this._date : date_week_start( this._date );
|
|
655
|
+
let dates: Date[] = [];
|
|
656
|
+
for( let i = 0; i < ( day ? 1 : 7 ); i++ ) {
|
|
657
|
+
dates.push( date_add_days( first, i ) );
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if( !day && !this._offDays && this._workDays.length ) {
|
|
661
|
+
dates = dates.filter( date => this._isWorkDay( date ) );
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// the band first: its height is taken from the one of the body
|
|
665
|
+
this._band.show( this._bandShown );
|
|
666
|
+
if( this._bandShown ) {
|
|
667
|
+
this._setBandHeight( this._layoutBars( this._items, dates ) );
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const height = this._body.dom.clientHeight;
|
|
671
|
+
if( !height ) {
|
|
672
|
+
return; // not shown yet: the resize will come
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// the time at the top of the view: it stays there when the height of an hour changes (a resize)
|
|
676
|
+
const top = this._scrolled ? this._tOf( this._body.dom.scrollTop ) : 0;
|
|
677
|
+
|
|
678
|
+
// the working hours fill the height, but for a margin above and under them where their first
|
|
679
|
+
// and last hours can be read; the others, as high, above and under them: the scroll reaches them
|
|
680
|
+
const [ workStart, workEnd ] = this._workHours;
|
|
681
|
+
const margin = this._offHours ? HOURS_MARGIN * 2 : 0;
|
|
682
|
+
this._hourPx = Math.max( MIN_STEP_PX * 60 / this._step, ( height - 1 - margin ) / ( ( workEnd - workStart ) / 60 ) );
|
|
683
|
+
|
|
684
|
+
const total = Math.ceil( this._yOf( DAY_MINUTES ) );
|
|
685
|
+
this._canvas.setStyle( { height: total } );
|
|
686
|
+
|
|
687
|
+
// the width of the body without its scroll bar
|
|
688
|
+
const width = this._body.dom.clientWidth - GUTTER;
|
|
689
|
+
const weights = dates.map( date => dates.length > 1 && !this._isWorkDay( date ) ? OFF_DAY_WEIGHT : 1 );
|
|
690
|
+
const sum = weights.reduce( ( a, b ) => a + b, 0 );
|
|
691
|
+
|
|
692
|
+
let x = GUTTER;
|
|
693
|
+
this._columns = dates.map( ( date, i ) => {
|
|
694
|
+
const w = width * weights[i] / sum;
|
|
695
|
+
const col = { date, work: this._isWorkDay( date ), x, w };
|
|
696
|
+
x += w;
|
|
697
|
+
return col;
|
|
698
|
+
} );
|
|
699
|
+
|
|
700
|
+
this._renderHead( );
|
|
701
|
+
this._renderGrid( x, total );
|
|
702
|
+
this._renderHours( );
|
|
703
|
+
this._renderItems( );
|
|
704
|
+
this._placeNow( );
|
|
705
|
+
|
|
706
|
+
if( !this._scrolled ) {
|
|
707
|
+
this._scrolled = true;
|
|
708
|
+
this._body.dom.scrollTop = this._yOf( workStart ) - HOURS_MARGIN;
|
|
709
|
+
}
|
|
710
|
+
else {
|
|
711
|
+
this._body.dom.scrollTop = this._yOf( top );
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// the days, over their columns: the name of the day, its number under it
|
|
716
|
+
private _renderHead( ) {
|
|
717
|
+
const today = date_hash( new Date( ) );
|
|
718
|
+
|
|
719
|
+
this._head.setContent( this._columns.map( col => {
|
|
720
|
+
const cell = new Component( { cls: "day", style: { left: col.x, width: col.w }, content: [
|
|
721
|
+
new Component( { cls: "name", content: date_format( col.date, { weekday: "short" } ) } ),
|
|
722
|
+
new Component( { cls: "num", content: "" + col.date.getDate( ) } ),
|
|
723
|
+
] } );
|
|
724
|
+
cell.setClass( "off", !col.work );
|
|
725
|
+
cell.setClass( "today", date_hash( col.date )==today );
|
|
726
|
+
return cell;
|
|
727
|
+
} ) );
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* behind the events, a path for each: the days off and the hours out of the working ones,
|
|
732
|
+
* today, the steps, the lines between the days and of the hours off, the lines of the working hours
|
|
733
|
+
*/
|
|
734
|
+
|
|
735
|
+
private _renderGrid( width: number, height: number ) {
|
|
736
|
+
const svg = new SvgBuilder( );
|
|
737
|
+
const off = svg.path( ).addClass( "off" );
|
|
738
|
+
const today = svg.path( ).addClass( "today" );
|
|
739
|
+
const steps = svg.path( ).addClass( "step" );
|
|
740
|
+
const lines = svg.path( ).addClass( "line" );
|
|
741
|
+
const hours = svg.path( ).addClass( "hour" );
|
|
742
|
+
const [ workStart, workEnd ] = this._workHours;
|
|
743
|
+
const [ shownStart, shownEnd ] = this._hoursShown( );
|
|
744
|
+
|
|
745
|
+
const rect = ( path: SvgPath, x1: number, y1: number, x2: number, y2: number ) => {
|
|
746
|
+
path.moveTo( x1, y1 ).lineTo( x2, y1 ).lineTo( x2, y2 ).lineTo( x1, y2 ).closePath( );
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
if( this._offHours ) {
|
|
750
|
+
rect( off, GUTTER, 0, width, this._yOf( workStart ) );
|
|
751
|
+
rect( off, GUTTER, this._yOf( workEnd ), width, height );
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const now = date_hash( new Date( ) );
|
|
755
|
+
this._columns.forEach( col => {
|
|
756
|
+
if( !col.work ) {
|
|
757
|
+
rect( off, col.x, 0, col.x + col.w, height );
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
if( date_hash( col.date )==now ) {
|
|
761
|
+
rect( today, col.x, 0, col.x + col.w, height );
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
lines.moveTo( col.x, 0 ).lineTo( col.x, height );
|
|
765
|
+
} );
|
|
766
|
+
|
|
767
|
+
// the hours shown: darker in the working ones; the steps from midnight, in the working hours
|
|
768
|
+
for( let h = 1; h < 24; h++ ) {
|
|
769
|
+
const t = h * 60;
|
|
770
|
+
if( t <= shownStart || t >= shownEnd ) {
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
const y = this._yOf( t );
|
|
775
|
+
( t >= workStart && t <= workEnd ? hours : lines ).moveTo( GUTTER, y ).lineTo( width, y );
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
for( let t = Math.ceil( workStart / this._step ) * this._step; t < workEnd; t += this._step ) {
|
|
779
|
+
if( t % 60 ) {
|
|
780
|
+
const y = this._yOf( t );
|
|
781
|
+
steps.moveTo( GUTTER, y ).lineTo( width, y );
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
this._grid.setAttribute( "width", width );
|
|
786
|
+
this._grid.setAttribute( "height", height );
|
|
787
|
+
this._grid.setAttribute( "viewBox", `0 0 ${width} ${height}` );
|
|
788
|
+
this._grid.setSvg( svg );
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// the hours shown on the left, but on the edges; those out of the working ones paler
|
|
792
|
+
private _renderHours( ) {
|
|
793
|
+
const [ workStart, workEnd ] = this._workHours;
|
|
794
|
+
const [ shownStart, shownEnd ] = this._hoursShown( );
|
|
795
|
+
const labels: Component[] = [];
|
|
796
|
+
|
|
797
|
+
for( let h = 1; h < 24; h++ ) {
|
|
798
|
+
const t = h * 60;
|
|
799
|
+
const work = t >= workStart && t <= workEnd;
|
|
800
|
+
if( t <= shownStart || t >= shownEnd ) {
|
|
801
|
+
continue;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const label = new Component( { cls: "hour", style: { top: this._yOf( t ) }, content: timeText( atMinutes( this._date, t ) ) } );
|
|
805
|
+
label.setClass( "work", work );
|
|
806
|
+
label.setInternalData( "y", this._yOf( t ) );
|
|
807
|
+
labels.push( label );
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
this._hours.setContent( labels );
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// the events: in the band and in the days, or in the month
|
|
814
|
+
private _renderItems( ) {
|
|
815
|
+
this._blocks = [];
|
|
816
|
+
|
|
817
|
+
if( this._view=="month" ) {
|
|
818
|
+
this._renderLines( );
|
|
819
|
+
}
|
|
820
|
+
else {
|
|
821
|
+
if( this._bandShown ) {
|
|
822
|
+
this._renderBand( );
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
this._renderEvents( );
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
this._renderRange( );
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* the events of whole days on the days shown: on the first line of the band where they have room
|
|
833
|
+
*/
|
|
834
|
+
|
|
835
|
+
private _layoutBars( items: PlannerItem[], dates: Date[] ): PlannerBar[] {
|
|
836
|
+
const bars: PlannerBar[] = [];
|
|
837
|
+
const ends: number[] = []; // the last column of each line
|
|
838
|
+
|
|
839
|
+
items
|
|
840
|
+
.filter( item => item.allDay )
|
|
841
|
+
.sort( ( a, b ) => a.start.getTime( ) - b.start.getTime( ) || b.end.getTime( ) - a.end.getTime( ) )
|
|
842
|
+
.forEach( item => {
|
|
843
|
+
// on the days shown only
|
|
844
|
+
const cols = indicesOf( dates, item.start, item.end );
|
|
845
|
+
if( !cols ) {
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const [ c0, c1 ] = cols;
|
|
850
|
+
let lane = ends.findIndex( end => end < c0 );
|
|
851
|
+
if( lane < 0 ) {
|
|
852
|
+
lane = ends.length;
|
|
853
|
+
ends.push( c1 );
|
|
854
|
+
}
|
|
855
|
+
else {
|
|
856
|
+
ends[lane] = c1;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
bars.push( { item, c0, c1, lane } );
|
|
860
|
+
} );
|
|
861
|
+
|
|
862
|
+
return bars;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// a line for each line of bars, one at least: a range of days can be selected in it
|
|
866
|
+
private _setBandHeight( bars: PlannerBar[] ) {
|
|
867
|
+
const lanes = bars.reduce( ( n, bar ) => Math.max( n, bar.lane + 1 ), 1 );
|
|
868
|
+
this._band.setStyle( { height: lanes * BAND_LINE + 4 } );
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// the band of the whole days: the days off, the bars, the range of days selected
|
|
872
|
+
private _renderBand( ) {
|
|
873
|
+
const cols = this._columns;
|
|
874
|
+
const bars = this._layoutBars( this._items, cols.map( col => col.date ) );
|
|
875
|
+
this._setBandHeight( bars );
|
|
876
|
+
|
|
877
|
+
const today = date_hash( new Date( ) );
|
|
878
|
+
const content: Component[] = cols.map( col => {
|
|
879
|
+
const day = new Component( { cls: "day", style: { left: col.x, width: col.w } } );
|
|
880
|
+
day.setClass( "off", !col.work );
|
|
881
|
+
day.setClass( "today", date_hash( col.date )==today );
|
|
882
|
+
return day;
|
|
883
|
+
} );
|
|
884
|
+
|
|
885
|
+
bars.forEach( bar => {
|
|
886
|
+
const x = cols[bar.c0].x + 2;
|
|
887
|
+
const block = this._makeBar( bar.item );
|
|
888
|
+
block.setStyle( { left: x, width: cols[bar.c1].x + cols[bar.c1].w - x - 3, top: bar.lane * BAND_LINE + 2, height: BAND_LINE - 2 } );
|
|
889
|
+
content.push( block );
|
|
890
|
+
} );
|
|
891
|
+
|
|
892
|
+
content.push( this._bandRange );
|
|
893
|
+
this._band.setContent( content );
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* the events in their days (those of whole days too, without the band); those that overlap
|
|
898
|
+
* share the width of their day, but for the strip on its right
|
|
899
|
+
*/
|
|
900
|
+
|
|
901
|
+
private _renderEvents( ) {
|
|
902
|
+
const items = this._bandShown ? this._items.filter( item => !item.allDay ) : this._items;
|
|
903
|
+
const blocks: Component[] = [];
|
|
904
|
+
this._parts = [];
|
|
905
|
+
|
|
906
|
+
this._columns.forEach( col => {
|
|
907
|
+
const segments = items.map( item => this._segmentOn( item, col.date ) ).filter( segment => !!segment );
|
|
908
|
+
segments.sort( ( a, b ) => a.from - b.from || a.to - b.to );
|
|
909
|
+
|
|
910
|
+
const free = Math.min( FREE_STRIP, col.w * 0.2 );
|
|
911
|
+
layoutLanes( segments ).forEach( ( { segment, lane, lanes } ) => {
|
|
912
|
+
const w = ( col.w - 4 - free ) / lanes;
|
|
913
|
+
const span = this._span( segment.from, segment.to );
|
|
914
|
+
|
|
915
|
+
const block = this._makeEvent( segment );
|
|
916
|
+
block.setStyle( { left: col.x + 2 + lane * w, width: w - 2, ...span } );
|
|
917
|
+
block.setClass( "small", span.height < SMALL_EVENT );
|
|
918
|
+
block.setInternalData( "segment", segment );
|
|
919
|
+
block.setInternalData( "alone", lanes==1 );
|
|
920
|
+
blocks.push( block );
|
|
921
|
+
this._parts.push( { col, top: span.top, bottom: span.top + span.height } );
|
|
922
|
+
} );
|
|
923
|
+
} );
|
|
924
|
+
|
|
925
|
+
this._layer.setContent( blocks );
|
|
926
|
+
this._renderEdges( );
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* the events out of the view, above or under it: in each day, a mark at the top or the bottom
|
|
931
|
+
* of the view, how many; a click on it scrolls to the nearest of them
|
|
932
|
+
*/
|
|
933
|
+
|
|
934
|
+
private _renderEdges( ) {
|
|
935
|
+
const body = this._body.dom;
|
|
936
|
+
const top = body.scrollTop;
|
|
937
|
+
const bottom = top + body.clientHeight;
|
|
938
|
+
const edges: Component[] = [];
|
|
939
|
+
|
|
940
|
+
this._columns.forEach( col => {
|
|
941
|
+
const parts = this._parts.filter( part => part.col===col );
|
|
942
|
+
const above = parts.filter( part => part.bottom <= top );
|
|
943
|
+
const under = parts.filter( part => part.top >= bottom );
|
|
944
|
+
|
|
945
|
+
if( above.length ) {
|
|
946
|
+
const nearest = above.reduce( ( a, b ) => b.bottom > a.bottom ? b : a );
|
|
947
|
+
edges.push( this._makeEdge( col, "up", above.length, top, ( ) => body.scrollTop = nearest.top - HOURS_MARGIN ) );
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if( under.length ) {
|
|
951
|
+
const nearest = under.reduce( ( a, b ) => b.top < a.top ? b : a );
|
|
952
|
+
edges.push( this._makeEdge( col, "down", under.length, bottom, ( ) => body.scrollTop = nearest.bottom - body.clientHeight + HOURS_MARGIN ) );
|
|
953
|
+
}
|
|
954
|
+
} );
|
|
955
|
+
|
|
956
|
+
this._edges.setContent( edges );
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// a mark of the events out of the view in a day, at y: the top of the view, or its bottom
|
|
960
|
+
private _makeEdge( col: PlannerColumn, dir: "up" | "down", count: number, y: number, click: ( ) => void ) {
|
|
961
|
+
return new Component( {
|
|
962
|
+
cls: "edge " + dir,
|
|
963
|
+
style: { left: col.x + col.w / 2, top: y },
|
|
964
|
+
content: ( dir=="up" ? "▲ " : "▼ " ) + count,
|
|
965
|
+
dom_events: { click },
|
|
966
|
+
} );
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* now, when today is shown, in the hours shown: a line across the days, over their events,
|
|
971
|
+
* paler across today; its time in the hours on the left, over the one it would cover
|
|
972
|
+
*/
|
|
973
|
+
|
|
974
|
+
private _placeNow( ) {
|
|
975
|
+
const now = new Date( );
|
|
976
|
+
const [ shownStart, shownEnd ] = this._hoursShown( );
|
|
977
|
+
const t = minutesOf( now );
|
|
978
|
+
const col = this._view=="month" || t < shownStart || t > shownEnd ? null : this._columnOf( now );
|
|
979
|
+
const shown = !!col;
|
|
980
|
+
const top = this._yOf( t );
|
|
981
|
+
const first = this._columns[0];
|
|
982
|
+
const last = this._columns[this._columns.length - 1];
|
|
983
|
+
|
|
984
|
+
this._now.show( shown && col!==first );
|
|
985
|
+
this._nowToday.show( shown );
|
|
986
|
+
this._nowAfter.show( shown && col!==last );
|
|
987
|
+
this._nowTime.show( shown );
|
|
988
|
+
|
|
989
|
+
if( shown ) {
|
|
990
|
+
this._now.setStyle( { left: first.x, width: col.x - first.x, top } );
|
|
991
|
+
this._nowToday.setStyle( { left: col.x, width: col.w, top } );
|
|
992
|
+
this._nowAfter.setStyle( { left: col.x + col.w, width: last.x + last.w - col.x - col.w, top } );
|
|
993
|
+
this._nowTime.setStyle( { top } );
|
|
994
|
+
this._nowTime.setContent( timeText( now ) );
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
this._hours.enumChildComponents( false ).forEach( label => {
|
|
998
|
+
label.setClass( "covered", shown && Math.abs( label.getInternalData<number>( "y" ) - top ) < 14 );
|
|
999
|
+
} );
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* the month of the date: its weeks from the monday before its first day to the sunday after
|
|
1004
|
+
* its last one, the days off narrower (or not shown)
|
|
1005
|
+
*/
|
|
1006
|
+
|
|
1007
|
+
private _renderMonth( ) {
|
|
1008
|
+
const month = new Date( this._date.getFullYear( ), this._date.getMonth( ), 1 );
|
|
1009
|
+
const first = date_week_start( month );
|
|
1010
|
+
const last = new Date( month.getFullYear( ), month.getMonth( ) + 1, 0 );
|
|
1011
|
+
const weeks = Math.ceil( ( daysBetween( first, last ) + 1 ) / 7 );
|
|
1012
|
+
const today = date_hash( new Date( ) );
|
|
1013
|
+
const shown = ( d: number ) => this._offDays || !this._workDays.length || this._workDays.includes( d );
|
|
1014
|
+
const week = WEEK.filter( shown );
|
|
1015
|
+
|
|
1016
|
+
this._month.setStyleVariable( "--planner-cols", week.map( d => this._workDays.includes( d ) ? "1fr" : OFF_DAY_WEIGHT_MONTH + "fr" ).join( " " ) );
|
|
1017
|
+
this._month.setStyleVariable( "--planner-rows", "" + weeks );
|
|
1018
|
+
|
|
1019
|
+
this._names.setContent( week.map( d => {
|
|
1020
|
+
const name = new Component( { cls: "name", content: _tr.global.day_short[d] } );
|
|
1021
|
+
name.setClass( "off", !this._workDays.includes( d ) );
|
|
1022
|
+
return name;
|
|
1023
|
+
} ) );
|
|
1024
|
+
|
|
1025
|
+
this._monthDays = [];
|
|
1026
|
+
for( let i = 0; i < weeks * 7; i++ ) {
|
|
1027
|
+
const date = date_add_days( first, i );
|
|
1028
|
+
if( !shown( date.getDay( ) ) ) {
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
const list = new Component( { cls: "list" } );
|
|
1033
|
+
const more = new Component( { cls: "more", hidden: true } );
|
|
1034
|
+
const cell = new Component( { cls: "day", content: [ new Component( { cls: "num", content: "" + date.getDate( ) } ), list, more ] } );
|
|
1035
|
+
|
|
1036
|
+
cell.setClass( "out", date.getMonth( )!=month.getMonth( ) );
|
|
1037
|
+
cell.setClass( "today", date_hash( date )==today );
|
|
1038
|
+
cell.setClass( "off", !this._isWorkDay( date ) );
|
|
1039
|
+
this._monthDays.push( { date, cell, list, more } );
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
this._cells.setContent( this._monthDays.map( day => day.cell ) );
|
|
1043
|
+
this._renderItems( );
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* the events of each day of the month, the whole days first; when they do not hold in it,
|
|
1048
|
+
* the last line tells how many are not shown
|
|
1049
|
+
*/
|
|
1050
|
+
|
|
1051
|
+
private _renderLines( ) {
|
|
1052
|
+
const items = this._items;
|
|
1053
|
+
|
|
1054
|
+
this._monthDays.forEach( day => {
|
|
1055
|
+
const from = day.date.getTime( );
|
|
1056
|
+
const next = date_add_days( day.date, 1 ).getTime( );
|
|
1057
|
+
|
|
1058
|
+
const list = items
|
|
1059
|
+
.filter( item => item.start.getTime( ) < next && ( item.end.getTime( ) > from || item.start.getTime( )==from ) )
|
|
1060
|
+
.sort( ( a, b ) => +b.allDay - +a.allDay || a.start.getTime( ) - b.start.getTime( ) || a.end.getTime( ) - b.end.getTime( ) );
|
|
1061
|
+
|
|
1062
|
+
// how many lines hold: all of them while the month is not shown
|
|
1063
|
+
const height = day.list.getBoundingRect( ).height + day.more.getBoundingRect( ).height;
|
|
1064
|
+
const room = height > 0 ? Math.max( 1, Math.floor( height / MONTH_LINE ) ) : list.length;
|
|
1065
|
+
const shown = list.length > room ? room - 1 : list.length;
|
|
1066
|
+
|
|
1067
|
+
day.list.setContent( list.slice( 0, shown ).map( item => this._makeLine( item, day.date ) ) );
|
|
1068
|
+
day.more.setContent( "+" + ( list.length - shown ) );
|
|
1069
|
+
day.more.show( list.length > shown );
|
|
1070
|
+
} );
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// the range selected: on a day of the canvas, on days of the band or of the month
|
|
1074
|
+
private _renderRange( ) {
|
|
1075
|
+
const range = this._range;
|
|
1076
|
+
|
|
1077
|
+
if( this._view=="month" ) {
|
|
1078
|
+
this._monthDays.forEach( day => {
|
|
1079
|
+
const date = day.date.getTime( );
|
|
1080
|
+
day.cell.setClass( "range", !!range && date >= range.start.getTime( ) && date < range.end.getTime( ) );
|
|
1081
|
+
} );
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
const cols = this._columns;
|
|
1086
|
+
const marks: Component[] = [];
|
|
1087
|
+
this._bandRange.hide( );
|
|
1088
|
+
|
|
1089
|
+
if( range && range.area=="canvas" ) {
|
|
1090
|
+
const col = this._columnOf( range.start );
|
|
1091
|
+
if( col ) {
|
|
1092
|
+
const top = this._yOf( minutesOf( range.start ) );
|
|
1093
|
+
|
|
1094
|
+
marks.push(
|
|
1095
|
+
new Component( { cls: "range", style: { left: col.x + 1, width: col.w - 2, top, height: this._yOf( this._minutesTo( col.date, range.end ) ) - top } } ),
|
|
1096
|
+
new Component( { cls: "range-label", style: { left: col.x + 6, top: top + 2 }, content: `${timeText( range.start )} – ${timeText( range.end )}` } ),
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
else if( range && range.area=="band" ) {
|
|
1101
|
+
const shown = indicesOf( cols.map( col => col.date ), range.start, range.end );
|
|
1102
|
+
if( shown ) {
|
|
1103
|
+
const [ c0, c1 ] = shown;
|
|
1104
|
+
this._bandRange.setStyle( { left: cols[c0].x + 1, width: cols[c1].x + cols[c1].w - cols[c0].x - 2 } );
|
|
1105
|
+
this._bandRange.show( );
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
this._marks.setContent( marks );
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// :: EVENTS DRAWN ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
1113
|
+
|
|
1114
|
+
// what each part of an event has: its colour, its tooltip, its classes
|
|
1115
|
+
private _makeBlock( item: PlannerItem, cls: string, content: Component[] ) {
|
|
1116
|
+
const event = item.event;
|
|
1117
|
+
const block = new Component( { cls: "event " + cls, tooltip: event.tooltip, content } );
|
|
1118
|
+
|
|
1119
|
+
block.setClass( "selected", event.id===this._selection );
|
|
1120
|
+
|
|
1121
|
+
// the stylesheet puts it on its border, its texts and its tint
|
|
1122
|
+
if( event.color ) {
|
|
1123
|
+
block.addClass( "colored" );
|
|
1124
|
+
block.setStyleVariable( "--planner-color", event.color );
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
if( event.cls ) {
|
|
1128
|
+
block.addClass( event.cls );
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
block.setInternalData( "item", item );
|
|
1132
|
+
this._blocks.push( block );
|
|
1133
|
+
return block;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// the title of an event, its icon on its right
|
|
1137
|
+
private _makeHead( event: PlannerEvent ) {
|
|
1138
|
+
const content: Component[] = [ new Component( { cls: "title", content: event.title } ) ];
|
|
1139
|
+
|
|
1140
|
+
if( event.icon ) {
|
|
1141
|
+
content.push( new Icon( { cls: "icon", iconId: event.icon } ) );
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
return new Component( { cls: "head", content } );
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// an event in a day: its title, its times (a whole day has none), its text; the ends that can be dragged, on the days it starts and ends
|
|
1148
|
+
private _makeEvent( segment: PlannerSegment ) {
|
|
1149
|
+
const item = segment.item;
|
|
1150
|
+
const event = item.event;
|
|
1151
|
+
const content: Component[] = [ this._makeHead( event ) ];
|
|
1152
|
+
|
|
1153
|
+
if( !item.allDay ) {
|
|
1154
|
+
content.push( new Component( { cls: "time", content: `${timeText( item.start )} – ${timeText( item.end )}` } ) );
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if( event.text ) {
|
|
1158
|
+
content.push( new Component( { cls: "text", content: event.text } ) );
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
if( !this._readonly ) {
|
|
1162
|
+
if( segment.first ) {
|
|
1163
|
+
content.push( new Component( { cls: "grip start" } ) );
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
if( segment.last ) {
|
|
1167
|
+
content.push( new Component( { cls: "grip end" } ) );
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
return this._makeBlock( item, "", content );
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// an event of whole days in the band: its title, its ends that can be dragged
|
|
1175
|
+
private _makeBar( item: PlannerItem ) {
|
|
1176
|
+
const content: Component[] = [ this._makeHead( item.event ) ];
|
|
1177
|
+
|
|
1178
|
+
if( !this._readonly ) {
|
|
1179
|
+
content.push( new Component( { cls: "grip start" } ), new Component( { cls: "grip end" } ) );
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
return this._makeBlock( item, "bar", content );
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// an event in a day of the month: the time it starts that day, its title
|
|
1186
|
+
private _makeLine( item: PlannerItem, day: Date ) {
|
|
1187
|
+
const content: Component[] = [ this._makeHead( item.event ) ];
|
|
1188
|
+
|
|
1189
|
+
if( !item.allDay && item.start.getTime( ) >= day.getTime( ) ) {
|
|
1190
|
+
content.unshift( new Component( { cls: "time", content: timeText( item.start ) } ) );
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
return this._makeBlock( item, "line", content );
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// :: GEOMETRY ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
1197
|
+
|
|
1198
|
+
private _isWorkDay( date: Date ) {
|
|
1199
|
+
return this._workDays.includes( date.getDay( ) );
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// the hours shown, in minutes from midnight: the whole day, or the working hours alone
|
|
1203
|
+
private _hoursShown( ): [ number, number ] {
|
|
1204
|
+
return this._offHours ? [ 0, DAY_MINUTES ] : this._workHours;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// the column of the day of a date, null when it is not shown
|
|
1208
|
+
private _columnOf( date: Date ): PlannerColumn {
|
|
1209
|
+
const day = dayOf( date ).getTime( );
|
|
1210
|
+
return this._columns.find( col => col.date.getTime( )==day ) ?? null;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* the part of an event on a day, in minutes from midnight; null: none, or out of the hours
|
|
1215
|
+
* shown. A whole day (without the band): the working hours, without ends to drag
|
|
1216
|
+
*/
|
|
1217
|
+
|
|
1218
|
+
private _segmentOn( item: PlannerItem, day: Date ): PlannerSegment {
|
|
1219
|
+
const from = day.getTime( );
|
|
1220
|
+
const next = date_add_days( day, 1 ).getTime( );
|
|
1221
|
+
const start = item.start.getTime( );
|
|
1222
|
+
const end = item.end.getTime( );
|
|
1223
|
+
|
|
1224
|
+
if( start >= next || end < from || ( end==from && start < from ) ) {
|
|
1225
|
+
return null;
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
if( item.allDay ) {
|
|
1229
|
+
const [ workStart, workEnd ] = this._workHours;
|
|
1230
|
+
return { item, from: workStart, to: workEnd, first: false, last: false };
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const segment = { item, from: start >= from ? minutesOf( item.start ) : 0, to: this._minutesTo( day, item.end ), first: start >= from, last: end <= next };
|
|
1234
|
+
const [ shownStart, shownEnd ] = this._hoursShown( );
|
|
1235
|
+
return segment.from >= shownEnd || segment.to <= shownStart ? null : segment;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// a time of a day (minutes) in the canvas, kept in the hours shown
|
|
1239
|
+
private _yOf( t: number ) {
|
|
1240
|
+
const [ shownStart, shownEnd ] = this._hoursShown( );
|
|
1241
|
+
return ( clamp( t, shownStart, shownEnd ) - shownStart ) / 60 * this._hourPx;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// the time at y in the canvas, in the hours shown
|
|
1245
|
+
private _tOf( y: number ) {
|
|
1246
|
+
const [ shownStart, shownEnd ] = this._hoursShown( );
|
|
1247
|
+
return clamp( shownStart + y / this._hourPx * 60, shownStart, shownEnd );
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
private _snap( t: number ) {
|
|
1251
|
+
return Math.floor( t / this._step ) * this._step;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
private _snapRound( t: number ) {
|
|
1255
|
+
return Math.round( t / this._step ) * this._step;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// a part of an event in the canvas, from a time of its day to another: on the line of its start,
|
|
1259
|
+
// the gap under it, 6px high at least, never past the bottom
|
|
1260
|
+
private _span( from: number, to: number ) {
|
|
1261
|
+
const top = this._yOf( from );
|
|
1262
|
+
const height = Math.max( this._yOf( to ) - top - EVENT_GAP, 6 );
|
|
1263
|
+
return { top: Math.min( top, this._yOf( DAY_MINUTES ) - height ), height };
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
// where an event that ends at a date stops on a day: 24:00 when it goes on after it
|
|
1267
|
+
private _minutesTo( day: Date, end: Date ) {
|
|
1268
|
+
return end.getTime( ) < date_add_days( day, 1 ).getTime( ) ? minutesOf( end ) : DAY_MINUTES;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// the column at x in the canvas or the band, kept in the columns
|
|
1272
|
+
private _colOf( x: number ) {
|
|
1273
|
+
const cols = this._columns;
|
|
1274
|
+
for( let i = 0; i < cols.length; i++ ) {
|
|
1275
|
+
if( x < cols[i].x + cols[i].w ) {
|
|
1276
|
+
return i;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
return cols.length - 1;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// the day of the month at a point of the window, -1: none
|
|
1284
|
+
private _dayAt( x: number, y: number ) {
|
|
1285
|
+
return this._monthDays.findIndex( day => day.cell.getBoundingRect( ).contains( { x, y } ) );
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// the part of the planner of an element
|
|
1289
|
+
private _areaOf( el: Element ): PlannerDrag["area"] {
|
|
1290
|
+
if( this._canvas.dom.contains( el ) ) {
|
|
1291
|
+
return "canvas";
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
if( this._band.dom.contains( el ) ) {
|
|
1295
|
+
return "band";
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
if( this._cells.dom.contains( el ) ) {
|
|
1299
|
+
return "month";
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
return null;
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// the day under the pointer, and its minutes in the canvas; null out of the days of the month
|
|
1306
|
+
private _pointAt( area: PlannerDrag["area"], ev: MouseEvent ): PlannerPoint {
|
|
1307
|
+
if( area=="month" ) {
|
|
1308
|
+
const i = this._dayAt( ev.clientX, ev.clientY );
|
|
1309
|
+
return i < 0 ? null : { day: this._monthDays[i].date, t: 0 };
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
const rc = ( area=="canvas" ? this._canvas : this._band ).getBoundingRect( );
|
|
1313
|
+
const col = this._columns[this._colOf( ev.clientX - rc.left )];
|
|
1314
|
+
return { day: col.date, t: area=="canvas" ? this._tOf( ev.clientY - rc.top ) : 0 };
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// the event of an element
|
|
1318
|
+
private _itemOf( el: Element ): PlannerItem {
|
|
1319
|
+
const block = Component.closest( el, ".event" );
|
|
1320
|
+
if( block && this.contains( block ) ) {
|
|
1321
|
+
return block.getInternalData<PlannerItem>( "item" );
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
return null;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// :: SELECTION :::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
1328
|
+
|
|
1329
|
+
private _select( item: PlannerItem, notify: boolean ) {
|
|
1330
|
+
const id = item ? item.event.id : undefined;
|
|
1331
|
+
if( id===this._selection ) {
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
this._selection = id;
|
|
1336
|
+
this._blocks.forEach( block => block.setClass( "selected", block.getInternalData<PlannerItem>( "item" ).event.id===id ) );
|
|
1337
|
+
|
|
1338
|
+
if( notify ) {
|
|
1339
|
+
this.fire( "selectionChange", { selection: item ? [ id ] : [], empty: !item } );
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// the week of a day of the month
|
|
1344
|
+
private _openWeek( date: Date ) {
|
|
1345
|
+
this._view = "week";
|
|
1346
|
+
this._date = dayOf( date );
|
|
1347
|
+
this._range = null;
|
|
1348
|
+
this._render( );
|
|
1349
|
+
this.fire( "viewChange", { view: this._view, date: this.getDate( ) } );
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
private _onResize( ) {
|
|
1353
|
+
if( !this._drag ) {
|
|
1354
|
+
this._render( );
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
private _onDblClick( ev: MouseEvent ) {
|
|
1359
|
+
const item = this._itemOf( ev.target as Element );
|
|
1360
|
+
if( item ) {
|
|
1361
|
+
this.fire( "dblClick", { context: item.event } );
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
if( this._view=="month" ) {
|
|
1366
|
+
const i = this._dayAt( ev.clientX, ev.clientY );
|
|
1367
|
+
if( i >= 0 ) {
|
|
1368
|
+
this._openWeek( this._monthDays[i].date );
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
private _onContextMenu( ev: MouseEvent ) {
|
|
1374
|
+
ev.preventDefault( );
|
|
1375
|
+
|
|
1376
|
+
const item = this._itemOf( ev.target as Element );
|
|
1377
|
+
if( item ) {
|
|
1378
|
+
this._select( item, true );
|
|
1379
|
+
this.fire( "contextMenu", { uievent: ev, context: item.event } );
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
private _onKey( ev: KeyboardEvent ) {
|
|
1384
|
+
const drag = this._drag;
|
|
1385
|
+
if( ev.key=="Escape" && drag ) {
|
|
1386
|
+
ev.preventDefault( );
|
|
1387
|
+
this._onPointerCancel( );
|
|
1388
|
+
|
|
1389
|
+
if( drag.moved ) {
|
|
1390
|
+
this.releaseCapture( drag.pointer );
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// :: DRAG ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* an event under the pointer: moved, or one of its ends; an empty place, the strip on the
|
|
1399
|
+
* right of a day or shift: a range. Nothing is taken before the pointer went 4px away: a
|
|
1400
|
+
* click stays a click
|
|
1401
|
+
*/
|
|
1402
|
+
|
|
1403
|
+
private _onPointerDown( ev: PointerEvent ) {
|
|
1404
|
+
if( ev.button!=0 ) {
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
const target = ev.target as Element;
|
|
1409
|
+
const area = this._areaOf( target );
|
|
1410
|
+
if( !area || Component.closest( target, ".edge" ) ) {
|
|
1411
|
+
return; // out of the days, or a mark of the events out of the view: its click
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// the hours, on the left
|
|
1415
|
+
let x = 0;
|
|
1416
|
+
if( area!="month" ) {
|
|
1417
|
+
x = ev.clientX - ( area=="canvas" ? this._canvas : this._band ).getBoundingRect( ).left;
|
|
1418
|
+
if( x < GUTTER ) {
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
const at = this._pointAt( area, ev );
|
|
1424
|
+
if( !at ) {
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
this._range = null;
|
|
1429
|
+
this._renderRange( );
|
|
1430
|
+
|
|
1431
|
+
let item = ev.shiftKey ? null : this._itemOf( target );
|
|
1432
|
+
const block = item ? Component.closest( target, ".event" ) : null;
|
|
1433
|
+
|
|
1434
|
+
if( item && area=="canvas" ) {
|
|
1435
|
+
const col = this._columns[this._colOf( x )];
|
|
1436
|
+
if( x > col.x + col.w - Math.min( FREE_STRIP, col.w * 0.2 ) ) {
|
|
1437
|
+
item = null;
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
let kind: PlannerDrag["kind"] = item ? "move" : "select";
|
|
1442
|
+
const grip = item ? Component.closest( target, ".grip" ) : null;
|
|
1443
|
+
if( grip ) {
|
|
1444
|
+
kind = grip.hasClass( "start" ) ? "start" : "end";
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
const t0 = kind=="select" ? Math.min( this._snap( at.t ), this._hoursShown( )[1] - this._step ) : at.t;
|
|
1448
|
+
this._drag = { area, kind, pointer: ev.pointerId, x: ev.clientX, y: ev.clientY, moved: false, copy: false, item, block, blocks: [], ghost: null, day: at.day, t0, preview: null };
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
private _onPointerMove( ev: PointerEvent ) {
|
|
1452
|
+
const drag = this._drag;
|
|
1453
|
+
if( !drag ) {
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
if( !drag.moved ) {
|
|
1458
|
+
// the button let go out of the planner, before any capture: no drag
|
|
1459
|
+
if( !( ev.buttons & 1 ) ) {
|
|
1460
|
+
this._drag = null;
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
if( Math.abs( ev.clientX - drag.x ) < 4 && Math.abs( ev.clientY - drag.y ) < 4 ) {
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// read only: no range, an event is only clicked
|
|
1469
|
+
if( this._readonly ) {
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
drag.moved = true;
|
|
1474
|
+
this.setCapture( ev.pointerId );
|
|
1475
|
+
this.addClass( drag.kind=="select" ? "selecting" : drag.kind=="move" ? "moving" : "sizing" );
|
|
1476
|
+
|
|
1477
|
+
if( drag.item ) {
|
|
1478
|
+
this._select( drag.item, true );
|
|
1479
|
+
drag.blocks = this._blocks.filter( block => block.getInternalData<PlannerItem>( "item" )===drag.item );
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
if( drag.area=="canvas" ) {
|
|
1484
|
+
this._autoScroll( ev );
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
const at = this._pointAt( drag.area, ev );
|
|
1488
|
+
if( !at ) {
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
if( drag.kind=="select" ) {
|
|
1493
|
+
this._range = this._rangeOf( drag, at );
|
|
1494
|
+
this._renderRange( );
|
|
1495
|
+
}
|
|
1496
|
+
else {
|
|
1497
|
+
drag.copy = drag.kind=="move" && isCopyKey( ev );
|
|
1498
|
+
drag.preview = this._place( drag, at );
|
|
1499
|
+
this._showDrag( drag, at, ev );
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* the drag on screen, nothing else drawn again: a move shows a ghost where the event goes, its
|
|
1505
|
+
* parts faded at their place unless it is copied; an end stretches the part under the pointer
|
|
1506
|
+
*/
|
|
1507
|
+
|
|
1508
|
+
private _showDrag( drag: PlannerDrag, at: PlannerPoint, ev: PointerEvent ) {
|
|
1509
|
+
const preview = drag.preview;
|
|
1510
|
+
const cols = this._columns;
|
|
1511
|
+
|
|
1512
|
+
if( drag.kind=="move" ) {
|
|
1513
|
+
drag.blocks.forEach( block => block.setClass( "faded", !drag.copy ) );
|
|
1514
|
+
this._removeGhost( drag );
|
|
1515
|
+
drag.ghost = this._makeGhost( drag, { ...drag.item, start: preview.start, end: preview.end }, at, ev );
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
const block = drag.block;
|
|
1520
|
+
if( drag.area=="canvas" ) {
|
|
1521
|
+
const segment = block.getInternalData<PlannerSegment>( "segment" );
|
|
1522
|
+
const span = this._span( drag.kind=="start" ? minutesOf( preview.start ) : segment.from, drag.kind=="end" ? this._minutesTo( drag.day, preview.end ) : segment.to );
|
|
1523
|
+
|
|
1524
|
+
block.setStyle( span );
|
|
1525
|
+
block.setClass( "small", span.height < SMALL_EVENT );
|
|
1526
|
+
block.query( ".time" ).setContent( `${timeText( preview.start )} – ${timeText( preview.end )}` );
|
|
1527
|
+
}
|
|
1528
|
+
else {
|
|
1529
|
+
const shown = indicesOf( cols.map( col => col.date ), preview.start, preview.end );
|
|
1530
|
+
if( shown ) {
|
|
1531
|
+
const [ c0, c1 ] = shown;
|
|
1532
|
+
const x = cols[c0].x + 2;
|
|
1533
|
+
block.setStyle( { left: x, width: cols[c1].x + cols[c1].w - x - 3 } );
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
/**
|
|
1539
|
+
* where the event moved goes, over the others: in the column of its new day, as wide as it is
|
|
1540
|
+
* and at its place in its column (alone in its day: as wide as the new one, shifted); in the
|
|
1541
|
+
* band on its new days, on its line; in the month under the pointer, the day it goes to framed
|
|
1542
|
+
*/
|
|
1543
|
+
|
|
1544
|
+
private _makeGhost( drag: PlannerDrag, item: PlannerItem, at: PlannerPoint, ev: PointerEvent ): Component {
|
|
1545
|
+
const cols = this._columns;
|
|
1546
|
+
let ghost: Component;
|
|
1547
|
+
|
|
1548
|
+
switch( drag.area ) {
|
|
1549
|
+
case "canvas": {
|
|
1550
|
+
// its first day shown, out of the hours shown: no ghost
|
|
1551
|
+
const col = this._columnOf( item.start );
|
|
1552
|
+
const segment = col ? this._segmentOn( item, col.date ) : null;
|
|
1553
|
+
if( !segment ) {
|
|
1554
|
+
return null;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
// not taken for another one: the width of the part dragged, at its place in its column;
|
|
1558
|
+
// alone in its day, the width of the new one, shifted
|
|
1559
|
+
const span = this._span( segment.from, segment.to );
|
|
1560
|
+
const block = drag.block.dom as HTMLElement;
|
|
1561
|
+
const from = this._columnOf( drag.day );
|
|
1562
|
+
|
|
1563
|
+
let left = col.x + ( from ? block.offsetLeft - from.x : 2 );
|
|
1564
|
+
let width = block.offsetWidth;
|
|
1565
|
+
if( drag.block.getInternalData<boolean>( "alone" ) ) {
|
|
1566
|
+
left = col.x + 2 + GHOST_SHIFT;
|
|
1567
|
+
width = Math.max( 20, col.w - 6 - Math.min( FREE_STRIP, col.w * 0.2 ) - GHOST_SHIFT );
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
ghost = this._makeEvent( segment );
|
|
1571
|
+
ghost.setStyle( { left, width, ...span } );
|
|
1572
|
+
ghost.setClass( "small", span.height < SMALL_EVENT );
|
|
1573
|
+
this._layer.appendContent( ghost );
|
|
1574
|
+
break;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
case "band": {
|
|
1578
|
+
const shown = indicesOf( cols.map( col => col.date ), item.start, item.end );
|
|
1579
|
+
if( !shown ) {
|
|
1580
|
+
return null;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
const [ c0, c1 ] = shown;
|
|
1584
|
+
const x = cols[c0].x + 2;
|
|
1585
|
+
|
|
1586
|
+
ghost = this._makeBar( item );
|
|
1587
|
+
ghost.setStyle( { left: x, width: cols[c1].x + cols[c1].w - x - 3, top: ( drag.block.dom as HTMLElement ).offsetTop, height: BAND_LINE - 2 } );
|
|
1588
|
+
this._band.appendContent( ghost );
|
|
1589
|
+
break;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
default: {
|
|
1593
|
+
const rc = this._cells.getBoundingRect( );
|
|
1594
|
+
|
|
1595
|
+
ghost = this._makeLine( item, at.day );
|
|
1596
|
+
ghost.setStyle( { left: ev.clientX - rc.left - 20, top: ev.clientY - rc.top - MONTH_LINE / 2, width: ( drag.block.dom as HTMLElement ).offsetWidth } );
|
|
1597
|
+
this._cells.appendContent( ghost );
|
|
1598
|
+
this._monthDays.forEach( day => day.cell.setClass( "drop", day.date.getTime( )==at.day.getTime( ) ) );
|
|
1599
|
+
break;
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
ghost.addClass( "dragging" );
|
|
1604
|
+
return ghost;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
private _removeGhost( drag: PlannerDrag ) {
|
|
1608
|
+
if( drag.ghost ) {
|
|
1609
|
+
drag.ghost.dom.remove( );
|
|
1610
|
+
drag.ghost = null;
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/**
|
|
1615
|
+
* a drag in the hours near the top or the bottom of the body: it scrolls while the pointer
|
|
1616
|
+
* stays there, the drag following the time under the pointer
|
|
1617
|
+
*/
|
|
1618
|
+
|
|
1619
|
+
private _autoScroll( ev: PointerEvent ) {
|
|
1620
|
+
const rc = this._body.getBoundingRect( );
|
|
1621
|
+
const dir = ev.clientY < rc.top + SCROLL_EDGE ? -1 : ev.clientY > rc.bottom - SCROLL_EDGE ? 1 : 0;
|
|
1622
|
+
|
|
1623
|
+
this._scrollEv = ev;
|
|
1624
|
+
if( dir==this._scrollDir ) {
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
this._scrollDir = dir;
|
|
1629
|
+
if( dir ) {
|
|
1630
|
+
this.setInterval( "autoscroll", 20, ( ) => {
|
|
1631
|
+
this._body.dom.scrollTop += this._scrollDir * SCROLL_STEP;
|
|
1632
|
+
this._onPointerMove( this._scrollEv );
|
|
1633
|
+
} );
|
|
1634
|
+
}
|
|
1635
|
+
else {
|
|
1636
|
+
this.clearInterval( "autoscroll" );
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
// the end of a drag: no scroll, no ghost, no day framed, the cursor of the planner back
|
|
1641
|
+
private _endDrag( drag: PlannerDrag ) {
|
|
1642
|
+
this._scrollDir = 0;
|
|
1643
|
+
this.clearInterval( "autoscroll" );
|
|
1644
|
+
this._removeGhost( drag );
|
|
1645
|
+
this._monthDays.forEach( day => day.cell.removeClass( "drop" ) );
|
|
1646
|
+
this.removeClass( "selecting moving sizing" );
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/**
|
|
1650
|
+
* a range: told by select; an event: the change is told before it is written, ev.preventDefault( )
|
|
1651
|
+
* puts it back; a copy is told, the application adds it
|
|
1652
|
+
*/
|
|
1653
|
+
|
|
1654
|
+
private _onPointerUp( ev: PointerEvent ) {
|
|
1655
|
+
const drag = this._drag;
|
|
1656
|
+
this._drag = null;
|
|
1657
|
+
|
|
1658
|
+
if( !drag ) {
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
if( !drag.moved ) {
|
|
1663
|
+
this._onTap( ev, drag );
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
this.releaseCapture( ev.pointerId );
|
|
1668
|
+
this._endDrag( drag );
|
|
1669
|
+
|
|
1670
|
+
if( drag.kind=="select" ) {
|
|
1671
|
+
const range = this._range;
|
|
1672
|
+
if( range ) {
|
|
1673
|
+
this.fire( "select", { start: range.start, end: endOf( range.start, range.end ) } );
|
|
1674
|
+
}
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
const preview = drag.preview;
|
|
1679
|
+
if( !preview ) {
|
|
1680
|
+
this._renderItems( );
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
const event = drag.item.event;
|
|
1685
|
+
const change: EvPlannerChange = { event, start: preview.start, end: endOf( preview.start, preview.end ) };
|
|
1686
|
+
|
|
1687
|
+
if( drag.kind=="move" && ( drag.copy || isCopyKey( ev ) ) ) {
|
|
1688
|
+
this.fire( "copy", change );
|
|
1689
|
+
this._renderItems( );
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
this.fire( "change", change );
|
|
1694
|
+
|
|
1695
|
+
if( !change.defaultPrevented ) {
|
|
1696
|
+
event.start = change.start;
|
|
1697
|
+
event.end = change.end;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
this._render( );
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
// a click: an event selected, or none; on the "+n" of a day of the month, its week
|
|
1704
|
+
private _onTap( ev: PointerEvent, drag: PlannerDrag ) {
|
|
1705
|
+
if( drag.area=="month" && Component.closest( ev.target as Element, ".more" ) ) {
|
|
1706
|
+
this._openWeek( drag.day );
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
this._select( drag.item, true );
|
|
1711
|
+
|
|
1712
|
+
if( drag.item ) {
|
|
1713
|
+
this.fire( "click", { context: drag.item.event } );
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// the browser took the pointer, the capture was lost, Escape: the event goes back, no range.
|
|
1718
|
+
// After a pointerup the drag is already over: the capture it releases changes nothing
|
|
1719
|
+
private _onPointerCancel( ) {
|
|
1720
|
+
const drag = this._drag;
|
|
1721
|
+
this._drag = null;
|
|
1722
|
+
|
|
1723
|
+
if( drag && drag.moved ) {
|
|
1724
|
+
this._endDrag( drag );
|
|
1725
|
+
this._range = null;
|
|
1726
|
+
this._renderItems( );
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// the range from where the pointer went down: the steps of its day in the canvas, else the days
|
|
1731
|
+
private _rangeOf( drag: PlannerDrag, at: PlannerPoint ): PlannerRange {
|
|
1732
|
+
if( drag.area=="canvas" ) {
|
|
1733
|
+
const t1 = Math.min( this._snap( at.t ), this._hoursShown( )[1] - this._step );
|
|
1734
|
+
return { area: drag.area, start: atMinutes( drag.day, Math.min( drag.t0, t1 ) ), end: atMinutes( drag.day, Math.max( drag.t0, t1 ) + this._step ) };
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
const after = at.day.getTime( ) > drag.day.getTime( );
|
|
1738
|
+
return { area: drag.area, start: after ? drag.day : at.day, end: date_add_days( after ? at.day : drag.day, 1 ) };
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
/**
|
|
1742
|
+
* where the event dragged goes: moved by the days and the steps the pointer went, or one of its
|
|
1743
|
+
* ends. In the canvas, an event of one day stays in the hours shown of its day: only its column
|
|
1744
|
+
* changes its day; a whole day changes of day only
|
|
1745
|
+
*/
|
|
1746
|
+
|
|
1747
|
+
private _place( drag: PlannerDrag, at: PlannerPoint ): { start: Date, end: Date } {
|
|
1748
|
+
const item = drag.item;
|
|
1749
|
+
const step = this._step * 60000;
|
|
1750
|
+
const canvas = drag.area=="canvas";
|
|
1751
|
+
|
|
1752
|
+
switch( drag.kind ) {
|
|
1753
|
+
case "move": {
|
|
1754
|
+
const days = daysBetween( drag.day, at.day );
|
|
1755
|
+
const minutes = canvas && !item.allDay ? this._snapRound( at.t - drag.t0 ) : 0;
|
|
1756
|
+
const length = item.end.getTime( ) - item.start.getTime( );
|
|
1757
|
+
|
|
1758
|
+
if( canvas && !item.allDay && daysBetween( dayOf( item.start ), dayOf( new Date( item.end.getTime( ) - 1 ) ) )==0 ) {
|
|
1759
|
+
const [ shownStart, shownEnd ] = this._hoursShown( );
|
|
1760
|
+
const t = clamp( minutesOf( item.start ) + minutes, shownStart, Math.max( shownStart, shownEnd - length / 60000 ) );
|
|
1761
|
+
const start = atMinutes( date_add_days( dayOf( item.start ), days ), t );
|
|
1762
|
+
return { start, end: new Date( start.getTime( ) + length ) };
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
const start = date_add_days( item.start, days );
|
|
1766
|
+
const end = date_add_days( item.end, days );
|
|
1767
|
+
start.setMinutes( start.getMinutes( ) + minutes );
|
|
1768
|
+
end.setMinutes( end.getMinutes( ) + minutes );
|
|
1769
|
+
return { start, end };
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
case "start": {
|
|
1773
|
+
if( canvas ) {
|
|
1774
|
+
const start = atMinutes( drag.day, this._snapRound( at.t ) ).getTime( );
|
|
1775
|
+
return { start: new Date( Math.min( start, item.end.getTime( ) - step ) ), end: item.end };
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
const last = date_add_days( item.end, -1 );
|
|
1779
|
+
return { start: at.day.getTime( ) > last.getTime( ) ? last : at.day, end: item.end };
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
default: {
|
|
1783
|
+
if( canvas ) {
|
|
1784
|
+
const end = atMinutes( drag.day, this._snapRound( at.t ) ).getTime( );
|
|
1785
|
+
return { start: item.start, end: new Date( Math.max( end, item.start.getTime( ) + step ) ) };
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
return { start: item.start, end: date_add_days( at.day.getTime( ) < item.start.getTime( ) ? item.start : at.day, 1 ) };
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
}
|