@arsedizioni/ars-utils 22.1.5 → 22.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -10
- package/clipper.ui/clipper.scss +564 -0
- package/fesm2022/arsedizioni-ars-utils-clipper.common.mjs +1 -1
- package/fesm2022/arsedizioni-ars-utils-clipper.common.mjs.map +1 -1
- package/fesm2022/arsedizioni-ars-utils-clipper.ui.mjs +11 -7
- package/fesm2022/arsedizioni-ars-utils-clipper.ui.mjs.map +1 -1
- package/fesm2022/arsedizioni-ars-utils-core.date.mjs +609 -0
- package/fesm2022/arsedizioni-ars-utils-core.date.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-core.markdown.mjs +591 -0
- package/fesm2022/arsedizioni-ars-utils-core.markdown.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-core.mjs +270 -1449
- package/fesm2022/arsedizioni-ars-utils-core.mjs.map +1 -1
- package/fesm2022/arsedizioni-ars-utils-evolution.common.mjs +1 -1
- package/fesm2022/arsedizioni-ars-utils-evolution.common.mjs.map +1 -1
- package/fesm2022/arsedizioni-ars-utils-support.common.mjs +1 -1
- package/fesm2022/arsedizioni-ars-utils-support.common.mjs.map +1 -1
- package/fesm2022/arsedizioni-ars-utils-ui.application.mjs +14 -4108
- package/fesm2022/arsedizioni-ars-utils-ui.application.mjs.map +1 -1
- package/fesm2022/arsedizioni-ars-utils-ui.controls.mjs +962 -0
- package/fesm2022/arsedizioni-ars-utils-ui.controls.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.dialogs.auth.mjs +506 -0
- package/fesm2022/arsedizioni-ars-utils-ui.dialogs.auth.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.dialogs.mjs +450 -0
- package/fesm2022/arsedizioni-ars-utils-ui.dialogs.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.dialogs.prompt.mjs +184 -0
- package/fesm2022/arsedizioni-ars-utils-ui.dialogs.prompt.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.dialogs.select.mjs +825 -0
- package/fesm2022/arsedizioni-ars-utils-ui.dialogs.select.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.files.mjs +708 -0
- package/fesm2022/arsedizioni-ars-utils-ui.files.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.filters.mjs +426 -0
- package/fesm2022/arsedizioni-ars-utils-ui.filters.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.help.mjs +531 -0
- package/fesm2022/arsedizioni-ars-utils-ui.help.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.mjs +54 -1227
- package/fesm2022/arsedizioni-ars-utils-ui.mjs.map +1 -1
- package/fesm2022/arsedizioni-ars-utils-ui.navigation.mjs +1049 -0
- package/fesm2022/arsedizioni-ars-utils-ui.navigation.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.notifications.mjs +263 -0
- package/fesm2022/arsedizioni-ars-utils-ui.notifications.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.oauth.mjs +4 -3
- package/fesm2022/arsedizioni-ars-utils-ui.oauth.mjs.map +1 -1
- package/fesm2022/arsedizioni-ars-utils-ui.paginator.mjs +53 -0
- package/fesm2022/arsedizioni-ars-utils-ui.paginator.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.shell.mjs +517 -0
- package/fesm2022/arsedizioni-ars-utils-ui.shell.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.tinymce.mjs +246 -0
- package/fesm2022/arsedizioni-ars-utils-ui.tinymce.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils.mjs +13 -12
- package/fesm2022/arsedizioni-ars-utils.mjs.map +1 -1
- package/help/package.json +4 -0
- package/package.json +80 -14
- package/styles/ui.colors.scss +23 -0
- package/support.ui/package.json +4 -0
- package/tinymce/package.json +4 -0
- package/types/arsedizioni-ars-utils-clipper.common.d.ts +1 -2
- package/types/arsedizioni-ars-utils-clipper.ui.d.ts +4 -1
- package/types/arsedizioni-ars-utils-core.d.ts +684 -1004
- package/types/arsedizioni-ars-utils-core.date.d.ts +304 -0
- package/types/arsedizioni-ars-utils-core.markdown.d.ts +159 -0
- package/types/arsedizioni-ars-utils-ui.application.d.ts +10 -2022
- package/types/arsedizioni-ars-utils-ui.controls.d.ts +413 -0
- package/types/arsedizioni-ars-utils-ui.d.ts +183 -566
- package/types/arsedizioni-ars-utils-ui.dialogs.auth.d.ts +224 -0
- package/types/arsedizioni-ars-utils-ui.dialogs.d.ts +228 -0
- package/types/arsedizioni-ars-utils-ui.dialogs.prompt.d.ts +47 -0
- package/types/arsedizioni-ars-utils-ui.dialogs.select.d.ts +309 -0
- package/types/arsedizioni-ars-utils-ui.files.d.ts +271 -0
- package/types/arsedizioni-ars-utils-ui.filters.d.ts +163 -0
- package/types/arsedizioni-ars-utils-ui.help.d.ts +220 -0
- package/types/arsedizioni-ars-utils-ui.navigation.d.ts +650 -0
- package/types/arsedizioni-ars-utils-ui.notifications.d.ts +73 -0
- package/types/arsedizioni-ars-utils-ui.paginator.d.ts +23 -0
- package/types/arsedizioni-ars-utils-ui.shell.d.ts +345 -0
- package/types/arsedizioni-ars-utils-ui.tinymce.d.ts +63 -0
- package/types/arsedizioni-ars-utils.d.ts +4 -10
- package/ui.tinymce/README.md +12 -0
- package/ui.tinymce/langs/it.js +466 -0
|
@@ -1,632 +1,16 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { EventEmitter, signal, computed, inject, ElementRef, afterNextRender, Directive, input, DestroyRef, HostListener, output, forwardRef, effect, Pipe, Service, PLATFORM_ID, RendererFactory2 } from '@angular/core';
|
|
3
|
+
import { SelectionModel } from '@angular/cdk/collections';
|
|
4
|
+
import { parseISO, parse, format, endOfDay } from 'date-fns';
|
|
5
|
+
import { it } from 'date-fns/locale';
|
|
4
6
|
import { TZDate } from '@date-fns/tz';
|
|
5
|
-
import { format, getYear, getMonth, getDate, getDay, getDaysInMonth, parseISO, parse, addYears, addMonths, addDays, isDate, isValid, addSeconds, endOfDay } from 'date-fns';
|
|
6
|
-
import { HTTP_INTERCEPTORS } from '@angular/common/http';
|
|
7
7
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
8
8
|
import { Subject, filter as filter$1, map as map$1, BehaviorSubject } from 'rxjs';
|
|
9
9
|
import { debounceTime, filter, map } from 'rxjs/operators';
|
|
10
|
-
import { it } from 'date-fns/locale';
|
|
11
10
|
import { NG_VALIDATORS } from '@angular/forms';
|
|
12
11
|
import { DomSanitizer } from '@angular/platform-browser';
|
|
13
|
-
import { SelectionModel } from '@angular/cdk/collections';
|
|
14
12
|
import { isPlatformBrowser } from '@angular/common';
|
|
15
13
|
|
|
16
|
-
/**
|
|
17
|
-
* Creates an array of the given length, filling each slot with the result of `valueFunction`.
|
|
18
|
-
* @param length - Number of elements to create.
|
|
19
|
-
* @param valueFunction - Factory called with each index to produce the element value.
|
|
20
|
-
* @returns Typed array of `length` elements.
|
|
21
|
-
*/
|
|
22
|
-
function range(length, valueFunction) {
|
|
23
|
-
const valuesArray = Array(length);
|
|
24
|
-
for (let i = 0; i < length; i++) {
|
|
25
|
-
valuesArray[i] = valueFunction(i);
|
|
26
|
-
}
|
|
27
|
-
return valuesArray;
|
|
28
|
-
}
|
|
29
|
-
// date-fns doesn't have a way to read/print month names or days of the week directly,
|
|
30
|
-
// so we get them by formatting a date with a format that produces the desired month/day.
|
|
31
|
-
const MONTH_FORMATS = {
|
|
32
|
-
long: 'LLLL',
|
|
33
|
-
short: 'LLL',
|
|
34
|
-
narrow: 'LLLLL',
|
|
35
|
-
};
|
|
36
|
-
const DAY_OF_WEEK_FORMATS = {
|
|
37
|
-
long: 'EEEE',
|
|
38
|
-
short: 'EEE',
|
|
39
|
-
narrow: 'EEEEE',
|
|
40
|
-
};
|
|
41
|
-
const MAT_DATE_FNS_FORMATS = {
|
|
42
|
-
parse: {
|
|
43
|
-
dateInput: 'P',
|
|
44
|
-
timeInput: ['p', 'HH:mm', 'H:mm'],
|
|
45
|
-
},
|
|
46
|
-
display: {
|
|
47
|
-
dateInput: 'P',
|
|
48
|
-
monthYearLabel: 'LLL uuuu',
|
|
49
|
-
dateA11yLabel: 'PP',
|
|
50
|
-
monthYearA11yLabel: 'LLLL uuuu',
|
|
51
|
-
timeInput: 'p',
|
|
52
|
-
timeOptionLabel: 'p',
|
|
53
|
-
},
|
|
54
|
-
};
|
|
55
|
-
/**
|
|
56
|
-
* date-fns adapter that integrates Angular Material's date picker with the date-fns library,
|
|
57
|
-
* applying `Europe/Rome` timezone for all parsed and created dates.
|
|
58
|
-
*/
|
|
59
|
-
class DateFnsAdapter extends DateAdapter {
|
|
60
|
-
constructor() {
|
|
61
|
-
super();
|
|
62
|
-
const matDateLocale = inject(MAT_DATE_LOCALE, { optional: true });
|
|
63
|
-
if (matDateLocale && typeof matDateLocale === 'object') {
|
|
64
|
-
this.setLocale(matDateLocale);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* Wraps a date/instant into a Europe/Rome TZDate whose JSON serialisation emits a
|
|
69
|
-
* naive local datetime string ("yyyy-MM-dd'T'HH:mm:ss") instead of a UTC instant,
|
|
70
|
-
* so the wall-clock value the user entered is preserved end-to-end (no timezone shift).
|
|
71
|
-
* @param value - The source Date or Unix timestamp in milliseconds.
|
|
72
|
-
* @returns a Europe/Rome TZDate whose toJSON yields a naive local datetime string.
|
|
73
|
-
*/
|
|
74
|
-
static toLocal(value) {
|
|
75
|
-
const ms = typeof value === 'number' ? value : value.getTime();
|
|
76
|
-
const t = new TZDate(ms, 'Europe/Rome');
|
|
77
|
-
Object.defineProperty(t, 'toJSON', {
|
|
78
|
-
value: () => format(t, "yyyy-MM-dd'T'HH:mm:ss"),
|
|
79
|
-
enumerable: false,
|
|
80
|
-
configurable: true,
|
|
81
|
-
writable: true,
|
|
82
|
-
});
|
|
83
|
-
return t;
|
|
84
|
-
}
|
|
85
|
-
/**
|
|
86
|
-
* Returns the year component of the given date.
|
|
87
|
-
* @param date - The source date.
|
|
88
|
-
*/
|
|
89
|
-
getYear(date) {
|
|
90
|
-
return getYear(date);
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* Returns the zero-based month index of the given date (0 = January).
|
|
94
|
-
* @param date - The source date.
|
|
95
|
-
*/
|
|
96
|
-
getMonth(date) {
|
|
97
|
-
return getMonth(date);
|
|
98
|
-
}
|
|
99
|
-
/**
|
|
100
|
-
* Returns the day-of-month of the given date (1-based).
|
|
101
|
-
* @param date - The source date.
|
|
102
|
-
*/
|
|
103
|
-
getDate(date) {
|
|
104
|
-
return getDate(date);
|
|
105
|
-
}
|
|
106
|
-
/**
|
|
107
|
-
* Returns the day-of-week of the given date (0 = Sunday).
|
|
108
|
-
* @param date - The source date.
|
|
109
|
-
*/
|
|
110
|
-
getDayOfWeek(date) {
|
|
111
|
-
return getDay(date);
|
|
112
|
-
}
|
|
113
|
-
/**
|
|
114
|
-
* Returns an array of 12 month name strings formatted for the active locale.
|
|
115
|
-
* @param style - One of `'long'`, `'short'`, or `'narrow'`.
|
|
116
|
-
*/
|
|
117
|
-
getMonthNames(style) {
|
|
118
|
-
const pattern = MONTH_FORMATS[style];
|
|
119
|
-
return range(12, i => this.format(new Date(2017, i, 1), pattern));
|
|
120
|
-
}
|
|
121
|
-
/**
|
|
122
|
-
* Returns an array of 31 day-of-month label strings formatted using `Intl.DateTimeFormat`
|
|
123
|
-
* when available, falling back to plain numeric strings.
|
|
124
|
-
*/
|
|
125
|
-
getDateNames() {
|
|
126
|
-
const dtf = typeof Intl !== 'undefined'
|
|
127
|
-
? new Intl.DateTimeFormat(this.locale?.code, {
|
|
128
|
-
day: 'numeric',
|
|
129
|
-
timeZone: 'utc',
|
|
130
|
-
})
|
|
131
|
-
: null;
|
|
132
|
-
return range(31, i => {
|
|
133
|
-
if (dtf) {
|
|
134
|
-
// date-fns doesn't appear to support this functionality.
|
|
135
|
-
// Fall back to `Intl` on supported browsers.
|
|
136
|
-
const date = new Date();
|
|
137
|
-
date.setUTCFullYear(2017, 0, i + 1);
|
|
138
|
-
date.setUTCHours(0, 0, 0, 0);
|
|
139
|
-
return dtf.format(date).replace(/[\u200e\u200f]/g, '');
|
|
140
|
-
}
|
|
141
|
-
return String(i + 1);
|
|
142
|
-
});
|
|
143
|
-
}
|
|
144
|
-
/**
|
|
145
|
-
* Returns an array of 7 day-of-week name strings formatted for the active locale.
|
|
146
|
-
* @param style - One of `'long'`, `'short'`, or `'narrow'`.
|
|
147
|
-
*/
|
|
148
|
-
getDayOfWeekNames(style) {
|
|
149
|
-
const pattern = DAY_OF_WEEK_FORMATS[style];
|
|
150
|
-
return range(7, i => this.format(new Date(2017, 0, i + 1), pattern));
|
|
151
|
-
}
|
|
152
|
-
/**
|
|
153
|
-
* Returns the four-digit year string for the given date.
|
|
154
|
-
* @param date - The source date.
|
|
155
|
-
*/
|
|
156
|
-
getYearName(date) {
|
|
157
|
-
return this.format(date, 'y');
|
|
158
|
-
}
|
|
159
|
-
/**
|
|
160
|
-
* Returns the first day of the week for the active locale (0 = Sunday, 1 = Monday, …).
|
|
161
|
-
*/
|
|
162
|
-
getFirstDayOfWeek() {
|
|
163
|
-
return this.locale?.options?.weekStartsOn ?? 0;
|
|
164
|
-
}
|
|
165
|
-
/**
|
|
166
|
-
* Returns the number of days in the month of the given date.
|
|
167
|
-
* @param date - The source date.
|
|
168
|
-
*/
|
|
169
|
-
getNumDaysInMonth(date) {
|
|
170
|
-
return getDaysInMonth(date);
|
|
171
|
-
}
|
|
172
|
-
/**
|
|
173
|
-
* Creates an independent copy of the given date.
|
|
174
|
-
* @param date - The date to clone.
|
|
175
|
-
*/
|
|
176
|
-
clone(date) {
|
|
177
|
-
return DateFnsAdapter.toLocal(date.getTime());
|
|
178
|
-
}
|
|
179
|
-
/**
|
|
180
|
-
* Creates a `Date` in the `Europe/Rome` timezone for the given year, month, and day.
|
|
181
|
-
* Throws an `Error` when any component is out of range.
|
|
182
|
-
* @param year - Full four-digit year.
|
|
183
|
-
* @param month - Zero-based month index (0 = January, 11 = December).
|
|
184
|
-
* @param date - Day-of-month (1-based).
|
|
185
|
-
*/
|
|
186
|
-
createDate(year, month, date) {
|
|
187
|
-
if (month < 0 || month > 11) {
|
|
188
|
-
throw Error(`Invalid month index "${month}". Month index has to be between 0 and 11.`);
|
|
189
|
-
}
|
|
190
|
-
if (date < 1) {
|
|
191
|
-
throw Error(`Invalid date "${date}". Date has to be greater than 0.`);
|
|
192
|
-
}
|
|
193
|
-
if (month < 0 || month > 11) {
|
|
194
|
-
throw Error(`Invalid month index "${month}". Month index has to be between 0 and 11.`);
|
|
195
|
-
}
|
|
196
|
-
const result = new TZDate(year, month, date, 0, 0, 0, 0, 'Europe/Rome');
|
|
197
|
-
if (result.getMonth() !== month) {
|
|
198
|
-
throw Error(`Invalid date "${date}" for month with index "${month}".`);
|
|
199
|
-
}
|
|
200
|
-
return DateFnsAdapter.toLocal(result);
|
|
201
|
-
}
|
|
202
|
-
/**
|
|
203
|
-
* Returns the current date and time in the Europe/Rome timezone, preserving the wall-clock value.
|
|
204
|
-
*/
|
|
205
|
-
today() {
|
|
206
|
-
return DateFnsAdapter.toLocal(new Date());
|
|
207
|
-
}
|
|
208
|
-
/**
|
|
209
|
-
* Parses a value into a `Date`.
|
|
210
|
-
* - Strings are first attempted as ISO 8601, then matched against each format in `parseFormat`.
|
|
211
|
-
* - Numbers are treated as Unix timestamps (milliseconds).
|
|
212
|
-
* - Existing `Date` instances are cloned.
|
|
213
|
-
* @param value - The value to parse.
|
|
214
|
-
* @param parseFormat - A format string or an array of format strings (date-fns tokens).
|
|
215
|
-
* @returns A valid `Date` in `Europe/Rome`, an invalid sentinel, or `null` for unrecognised input.
|
|
216
|
-
*/
|
|
217
|
-
parse(value, parseFormat) {
|
|
218
|
-
if (typeof value === 'string' && value.length > 0) {
|
|
219
|
-
const iso8601Date = parseISO(value);
|
|
220
|
-
if (this.isValid(iso8601Date)) {
|
|
221
|
-
return DateFnsAdapter.toLocal(iso8601Date);
|
|
222
|
-
}
|
|
223
|
-
const formats = Array.isArray(parseFormat) ? parseFormat : [parseFormat];
|
|
224
|
-
if (!formats.length) {
|
|
225
|
-
throw Error('Formats array must not be empty.');
|
|
226
|
-
}
|
|
227
|
-
for (const currentFormat of formats) {
|
|
228
|
-
const fromFormat = parse(value, currentFormat, new TZDate(new Date(), 'Europe/Rome'), {
|
|
229
|
-
locale: this.locale
|
|
230
|
-
});
|
|
231
|
-
if (this.isValid(fromFormat)) {
|
|
232
|
-
return DateFnsAdapter.toLocal(fromFormat);
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
return this.invalid();
|
|
236
|
-
}
|
|
237
|
-
else if (typeof value === 'number') {
|
|
238
|
-
return DateFnsAdapter.toLocal(value);
|
|
239
|
-
}
|
|
240
|
-
else if (value instanceof Date) {
|
|
241
|
-
return this.clone(value);
|
|
242
|
-
}
|
|
243
|
-
return null;
|
|
244
|
-
}
|
|
245
|
-
/**
|
|
246
|
-
* Formats a `Date` using the given date-fns display format string.
|
|
247
|
-
* Throws an `Error` when `date` is not valid.
|
|
248
|
-
* @param date - The date to format.
|
|
249
|
-
* @param displayFormat - A date-fns format string (e.g. `'P'`, `'LLL uuuu'`).
|
|
250
|
-
*/
|
|
251
|
-
format(date, displayFormat) {
|
|
252
|
-
if (!this.isValid(date)) {
|
|
253
|
-
throw Error('DateFnsAdapter: Cannot format invalid date.');
|
|
254
|
-
}
|
|
255
|
-
return format(date, displayFormat, { locale: this.locale });
|
|
256
|
-
}
|
|
257
|
-
/**
|
|
258
|
-
* Adds the given number of whole years to a date.
|
|
259
|
-
* @param date - The base date.
|
|
260
|
-
* @param years - Number of years to add (can be negative).
|
|
261
|
-
*/
|
|
262
|
-
addCalendarYears(date, years) {
|
|
263
|
-
return DateFnsAdapter.toLocal(addYears(date, years));
|
|
264
|
-
}
|
|
265
|
-
/**
|
|
266
|
-
* Adds the given number of whole months to a date.
|
|
267
|
-
* @param date - The base date.
|
|
268
|
-
* @param months - Number of months to add (can be negative).
|
|
269
|
-
*/
|
|
270
|
-
addCalendarMonths(date, months) {
|
|
271
|
-
return DateFnsAdapter.toLocal(addMonths(date, months));
|
|
272
|
-
}
|
|
273
|
-
/**
|
|
274
|
-
* Adds the given number of whole days to a date.
|
|
275
|
-
* @param date - The base date.
|
|
276
|
-
* @param days - Number of days to add (can be negative).
|
|
277
|
-
*/
|
|
278
|
-
addCalendarDays(date, days) {
|
|
279
|
-
return DateFnsAdapter.toLocal(addDays(date, days));
|
|
280
|
-
}
|
|
281
|
-
/**
|
|
282
|
-
* Serialises a date to an ISO 8601 date-only string (`yyyy-MM-dd`) using its Europe/Rome calendar day,
|
|
283
|
-
* independent of the browser timezone.
|
|
284
|
-
* @param date - The date to serialise.
|
|
285
|
-
* @returns the yyyy-MM-dd string for the date in Europe/Rome.
|
|
286
|
-
*/
|
|
287
|
-
toIso8601(date) {
|
|
288
|
-
return format(new TZDate(date, 'Europe/Rome'), 'yyyy-MM-dd');
|
|
289
|
-
}
|
|
290
|
-
/**
|
|
291
|
-
* Returns the given value when it is a valid `Date`, or `null` for an empty string.
|
|
292
|
-
* Deserialises valid ISO 8601 strings into `Date` instances.
|
|
293
|
-
* Delegates all other values to the base-class implementation.
|
|
294
|
-
* @param value - The raw value to deserialise.
|
|
295
|
-
*/
|
|
296
|
-
deserialize(value) {
|
|
297
|
-
if (typeof value === 'string') {
|
|
298
|
-
if (!value) {
|
|
299
|
-
return null;
|
|
300
|
-
}
|
|
301
|
-
const date = parseISO(value);
|
|
302
|
-
if (this.isValid(date)) {
|
|
303
|
-
return DateFnsAdapter.toLocal(date);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
return super.deserialize(value);
|
|
307
|
-
}
|
|
308
|
-
/**
|
|
309
|
-
* Returns `true` when `obj` is an instance of `Date`.
|
|
310
|
-
* @param obj - The object to test.
|
|
311
|
-
*/
|
|
312
|
-
isDateInstance(obj) {
|
|
313
|
-
return isDate(obj);
|
|
314
|
-
}
|
|
315
|
-
/**
|
|
316
|
-
* Returns `true` when `date` represents a valid point in time.
|
|
317
|
-
* @param date - The date to validate.
|
|
318
|
-
*/
|
|
319
|
-
isValid(date) {
|
|
320
|
-
return isValid(date);
|
|
321
|
-
}
|
|
322
|
-
/**
|
|
323
|
-
* Returns a sentinel `Date` that represents an invalid date (`new Date(NaN)`).
|
|
324
|
-
*/
|
|
325
|
-
invalid() {
|
|
326
|
-
return new Date(NaN);
|
|
327
|
-
}
|
|
328
|
-
/**
|
|
329
|
-
* Returns the hours component (0-23) of the given date.
|
|
330
|
-
* @param date - The source date.
|
|
331
|
-
*/
|
|
332
|
-
getHours(date) {
|
|
333
|
-
return date.getHours();
|
|
334
|
-
}
|
|
335
|
-
/**
|
|
336
|
-
* Returns the minutes component (0-59) of the given date.
|
|
337
|
-
* @param date - The source date.
|
|
338
|
-
*/
|
|
339
|
-
getMinutes(date) {
|
|
340
|
-
return date.getMinutes();
|
|
341
|
-
}
|
|
342
|
-
/**
|
|
343
|
-
* Returns the seconds component (0-59) of the given date.
|
|
344
|
-
* @param date - The source date.
|
|
345
|
-
*/
|
|
346
|
-
getSeconds(date) {
|
|
347
|
-
return date.getSeconds();
|
|
348
|
-
}
|
|
349
|
-
/**
|
|
350
|
-
* Returns a clone of `target` with its time-of-day set to the given hours/minutes/seconds,
|
|
351
|
-
* keeping the date part unchanged.
|
|
352
|
-
* @param target - The date whose time should be set.
|
|
353
|
-
* @param hours - New hours (0-23).
|
|
354
|
-
* @param minutes - New minutes (0-59).
|
|
355
|
-
* @param seconds - New seconds (0-59).
|
|
356
|
-
*/
|
|
357
|
-
setTime(target, hours, minutes, seconds) {
|
|
358
|
-
const clone = this.clone(target);
|
|
359
|
-
clone.setHours(hours, minutes, seconds, 0);
|
|
360
|
-
return clone;
|
|
361
|
-
}
|
|
362
|
-
/**
|
|
363
|
-
* Adds the given number of seconds to a date.
|
|
364
|
-
* @param date - The base date.
|
|
365
|
-
* @param amount - Number of seconds to add (can be negative).
|
|
366
|
-
*/
|
|
367
|
-
addSeconds(date, amount) {
|
|
368
|
-
return DateFnsAdapter.toLocal(addSeconds(date, amount));
|
|
369
|
-
}
|
|
370
|
-
/**
|
|
371
|
-
* Parses a time-only value into a `Date` (today's date, in `Europe/Rome`, with the parsed
|
|
372
|
-
* time-of-day set). The wall-clock time is preserved exactly as entered.
|
|
373
|
-
* @param value - The value to parse (a string, or an existing `Date`).
|
|
374
|
-
* @param parseFormat - A format string or an array of format strings (date-fns tokens).
|
|
375
|
-
* @returns the parsed Date, an invalid sentinel, or null for unrecognised input.
|
|
376
|
-
*/
|
|
377
|
-
parseTime(value, parseFormat) {
|
|
378
|
-
if (typeof value === 'string' && value.length > 0) {
|
|
379
|
-
const formats = Array.isArray(parseFormat) ? parseFormat : [parseFormat];
|
|
380
|
-
if (!formats.length) {
|
|
381
|
-
throw Error('Formats array must not be empty.');
|
|
382
|
-
}
|
|
383
|
-
const referenceDate = new TZDate(new Date(), 'Europe/Rome');
|
|
384
|
-
for (const currentFormat of formats) {
|
|
385
|
-
const fromFormat = parse(value, currentFormat, referenceDate, { locale: this.locale });
|
|
386
|
-
if (this.isValid(fromFormat)) {
|
|
387
|
-
return DateFnsAdapter.toLocal(fromFormat);
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
return this.invalid();
|
|
391
|
-
}
|
|
392
|
-
else if (value instanceof Date) {
|
|
393
|
-
return this.clone(value);
|
|
394
|
-
}
|
|
395
|
-
return null;
|
|
396
|
-
}
|
|
397
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: DateFnsAdapter, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
398
|
-
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: DateFnsAdapter }); }
|
|
399
|
-
}
|
|
400
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: DateFnsAdapter, decorators: [{
|
|
401
|
-
type: Injectable
|
|
402
|
-
}], ctorParameters: () => [] });
|
|
403
|
-
/**
|
|
404
|
-
* Standalone providers for the ARS date-fns adapter.
|
|
405
|
-
*
|
|
406
|
-
* Configures Angular Material to use {@link DateFnsAdapter} (Europe/Rome timezone)
|
|
407
|
-
* and the matching {@link MAT_DATE_FNS_FORMATS}. Also supports `mat-timepicker` since
|
|
408
|
-
* {@link DateFnsAdapter} implements the time-related `DateAdapter` methods.
|
|
409
|
-
*
|
|
410
|
-
* @example
|
|
411
|
-
* bootstrapApplication(AppComponent, {
|
|
412
|
-
* providers: [provideArsDateFns()]
|
|
413
|
-
* });
|
|
414
|
-
*/
|
|
415
|
-
function provideArsDateFns() {
|
|
416
|
-
return makeEnvironmentProviders([
|
|
417
|
-
{
|
|
418
|
-
provide: DateAdapter,
|
|
419
|
-
useClass: DateFnsAdapter
|
|
420
|
-
},
|
|
421
|
-
{ provide: MAT_DATE_FORMATS, useValue: MAT_DATE_FNS_FORMATS }
|
|
422
|
-
]);
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/** Default application timezone: all dates are serialised as Europe/Rome wall-clock values. */
|
|
426
|
-
const DEFAULT_TIME_ZONE = 'Europe/Rome';
|
|
427
|
-
/**
|
|
428
|
-
* IANA timezone used to serialise dates towards the backend.
|
|
429
|
-
* Override it in the application providers to run the same code in another zone:
|
|
430
|
-
* `{ provide: ARS_TIME_ZONE, useValue: 'Europe/Zurich' }`.
|
|
431
|
-
*/
|
|
432
|
-
const ARS_TIME_ZONE = new InjectionToken('ARS_TIME_ZONE', {
|
|
433
|
-
providedIn: 'root',
|
|
434
|
-
factory: () => DEFAULT_TIME_ZONE,
|
|
435
|
-
});
|
|
436
|
-
/** Naive local datetime pattern (no timezone designator): the server reads it as DateTimeKind.Unspecified. */
|
|
437
|
-
const NAIVE_PATTERN = "yyyy-MM-dd'T'HH:mm:ss";
|
|
438
|
-
/**
|
|
439
|
-
* Returns `true` for payloads that must be forwarded untouched
|
|
440
|
-
* (binary or already-encoded bodies).
|
|
441
|
-
* @param body - The request body to test.
|
|
442
|
-
*/
|
|
443
|
-
function isOpaqueBody(body) {
|
|
444
|
-
return (body instanceof FormData ||
|
|
445
|
-
body instanceof Blob ||
|
|
446
|
-
body instanceof ArrayBuffer ||
|
|
447
|
-
ArrayBuffer.isView(body) ||
|
|
448
|
-
body instanceof URLSearchParams ||
|
|
449
|
-
typeof body === 'string');
|
|
450
|
-
}
|
|
451
|
-
/**
|
|
452
|
-
* Recursively replaces every `Date` found in `value` with its naive local
|
|
453
|
-
* datetime string, returning a NEW structure: the original object graph — and
|
|
454
|
-
* therefore any bound form model — is never mutated.
|
|
455
|
-
*
|
|
456
|
-
* @param value - The value to normalise (object, array, Date or primitive).
|
|
457
|
-
* @param timeZone - IANA timezone used to compute the wall-clock value.
|
|
458
|
-
* @param seen - Guard against circular references.
|
|
459
|
-
* @returns The normalised value, safe to hand over to `JSON.stringify`.
|
|
460
|
-
*/
|
|
461
|
-
function normalizeDates(value, timeZone, seen) {
|
|
462
|
-
if (value instanceof Date) {
|
|
463
|
-
// Invalid dates would make format() throw a RangeError: send null instead.
|
|
464
|
-
if (isNaN(value.getTime()))
|
|
465
|
-
return null;
|
|
466
|
-
return format(new TZDate(value.getTime(), timeZone), NAIVE_PATTERN);
|
|
467
|
-
}
|
|
468
|
-
if (value === null || typeof value !== 'object')
|
|
469
|
-
return value;
|
|
470
|
-
if (isOpaqueBody(value))
|
|
471
|
-
return value;
|
|
472
|
-
// Circular reference: leave it as is, JSON.stringify will report it anyway.
|
|
473
|
-
if (seen.has(value))
|
|
474
|
-
return value;
|
|
475
|
-
seen.add(value);
|
|
476
|
-
if (Array.isArray(value)) {
|
|
477
|
-
return value.map(item => normalizeDates(item, timeZone, seen));
|
|
478
|
-
}
|
|
479
|
-
const source = value;
|
|
480
|
-
const result = {};
|
|
481
|
-
for (const key of Object.keys(source)) {
|
|
482
|
-
result[key] = normalizeDates(source[key], timeZone, seen);
|
|
483
|
-
}
|
|
484
|
-
return result;
|
|
485
|
-
}
|
|
486
|
-
/**
|
|
487
|
-
* Rewrites the request body, if any, replacing Dates with naive local strings.
|
|
488
|
-
* Shared by the functional and the class-based interceptor.
|
|
489
|
-
* @param req - The outgoing request.
|
|
490
|
-
* @param timeZone - IANA timezone used to compute the wall-clock value.
|
|
491
|
-
* @returns The original request when there is nothing to convert, a clone otherwise.
|
|
492
|
-
*/
|
|
493
|
-
function localizeRequest(req, timeZone) {
|
|
494
|
-
const body = req.body;
|
|
495
|
-
if (body === null || body === undefined || isOpaqueBody(body))
|
|
496
|
-
return req;
|
|
497
|
-
return req.clone({ body: normalizeDates(body, timeZone, new WeakSet()) });
|
|
498
|
-
}
|
|
499
|
-
/**
|
|
500
|
-
* Functional interceptor that serialises every `Date` in an outgoing request
|
|
501
|
-
* body as a naive local datetime string (`yyyy-MM-ddTHH:mm:ss`: no `Z`, no offset).
|
|
502
|
-
*
|
|
503
|
-
* WHY: `JSON.stringify` calls `Date.prototype.toJSON`, which emits a UTC instant.
|
|
504
|
-
* A date picked as 29/07/2026 00:00 in Rome (UTC+2) becomes
|
|
505
|
-
* `2026-07-28T22:00:00.000Z`, so the server stores the 28th — the classic
|
|
506
|
-
* "off by one day" bug. Sending the wall-clock value instead makes
|
|
507
|
-
* System.Text.Json produce a `DateTime` with `Kind = Unspecified` and the exact
|
|
508
|
-
* day/time the user selected.
|
|
509
|
-
*
|
|
510
|
-
* This is the single serialisation boundary of the application: no call site has
|
|
511
|
-
* to convert anything, and it also covers dates that lost a per-instance
|
|
512
|
-
* `toJSON` along the way (e.g. after `structuredClone`).
|
|
513
|
-
*
|
|
514
|
-
* Register it explicitly in `provideHttpClient(withInterceptors([...]))`.
|
|
515
|
-
* If you would rather have the library register it for you, use
|
|
516
|
-
* {@link provideArsLocalDates} instead.
|
|
517
|
-
*
|
|
518
|
-
* @example
|
|
519
|
-
* provideHttpClient(withInterceptors([
|
|
520
|
-
* arsLocalDateInterceptor,
|
|
521
|
-
* evolutionAuthInterceptor(...),
|
|
522
|
-
* ]));
|
|
523
|
-
*/
|
|
524
|
-
const arsLocalDateInterceptor = (req, next) => next(localizeRequest(req, inject(ARS_TIME_ZONE)));
|
|
525
|
-
/**
|
|
526
|
-
* Class-based twin of {@link arsLocalDateInterceptor}.
|
|
527
|
-
*
|
|
528
|
-
* Exists because a functional `HttpInterceptorFn` can ONLY be registered inside
|
|
529
|
-
* `provideHttpClient(withInterceptors([...]))`, while a class registered on the
|
|
530
|
-
* `HTTP_INTERCEPTORS` multi-token can be contributed by any `EnvironmentProviders` —
|
|
531
|
-
* which is what lets {@link provideArsLocalDates} (and `provideArsDateFns`) wire it up
|
|
532
|
-
* on their own.
|
|
533
|
-
*
|
|
534
|
-
* REQUIRES `withInterceptorsFromDi()` in the application's `provideHttpClient()`:
|
|
535
|
-
* without it Angular never reads `HTTP_INTERCEPTORS` and this interceptor is
|
|
536
|
-
* silently skipped.
|
|
537
|
-
*/
|
|
538
|
-
class ArsLocalDateInterceptor {
|
|
539
|
-
constructor() {
|
|
540
|
-
this.timeZone = inject(ARS_TIME_ZONE);
|
|
541
|
-
}
|
|
542
|
-
/**
|
|
543
|
-
* Normalises the request body before handing it to the next handler.
|
|
544
|
-
* @param req - The outgoing request.
|
|
545
|
-
* @param next - The next handler in the chain.
|
|
546
|
-
*/
|
|
547
|
-
intercept(req, next) {
|
|
548
|
-
return next.handle(localizeRequest(req, this.timeZone));
|
|
549
|
-
}
|
|
550
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ArsLocalDateInterceptor, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
551
|
-
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ArsLocalDateInterceptor }); }
|
|
552
|
-
}
|
|
553
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: ArsLocalDateInterceptor, decorators: [{
|
|
554
|
-
type: Injectable
|
|
555
|
-
}] });
|
|
556
|
-
/**
|
|
557
|
-
* Standalone providers for date serialisation towards the backend.
|
|
558
|
-
*
|
|
559
|
-
* Registers {@link ArsLocalDateInterceptor} so that every `Date` in a request
|
|
560
|
-
* body travels as a naive local datetime string instead of a UTC instant.
|
|
561
|
-
*
|
|
562
|
-
* IMPORTANT: the application MUST call `withInterceptorsFromDi()`, otherwise
|
|
563
|
-
* these providers have no effect at all (no error is raised).
|
|
564
|
-
*
|
|
565
|
-
* @param timeZone - IANA timezone name (default: `Europe/Rome`).
|
|
566
|
-
*
|
|
567
|
-
* @example
|
|
568
|
-
* providers: [
|
|
569
|
-
* provideArsLocalDates(),
|
|
570
|
-
* provideHttpClient(withInterceptors([evolutionAuthInterceptor(...)]), withInterceptorsFromDi()),
|
|
571
|
-
* ]
|
|
572
|
-
*/
|
|
573
|
-
function provideArsLocalDates(timeZone = DEFAULT_TIME_ZONE) {
|
|
574
|
-
return makeEnvironmentProviders([
|
|
575
|
-
{ provide: ARS_TIME_ZONE, useValue: timeZone },
|
|
576
|
-
{ provide: HTTP_INTERCEPTORS, useClass: ArsLocalDateInterceptor, multi: true },
|
|
577
|
-
]);
|
|
578
|
-
}
|
|
579
|
-
/**
|
|
580
|
-
* Formats a date as a date-only ISO string (`yyyy-MM-dd`) using its wall-clock
|
|
581
|
-
* calendar day in the given timezone. Use it for query-string parameters and for
|
|
582
|
-
* DTO fields mapped to a .NET `DateOnly`, which the interceptor cannot detect.
|
|
583
|
-
*
|
|
584
|
-
* @param value - The date to serialise.
|
|
585
|
-
* @param timeZone - IANA timezone name (default: `Europe/Rome`).
|
|
586
|
-
* @returns The `yyyy-MM-dd` string, or `undefined` for empty/invalid input.
|
|
587
|
-
*/
|
|
588
|
-
function toLocalDateOnlyString(value, timeZone = DEFAULT_TIME_ZONE) {
|
|
589
|
-
if (!value || isNaN(value.getTime()))
|
|
590
|
-
return undefined;
|
|
591
|
-
return format(new TZDate(value.getTime(), timeZone), 'yyyy-MM-dd');
|
|
592
|
-
}
|
|
593
|
-
/**
|
|
594
|
-
* Formats a date as a naive local datetime string (`yyyy-MM-ddTHH:mm:ss`), the
|
|
595
|
-
* same representation produced by the interceptor. Useful for query-string
|
|
596
|
-
* parameters, which never pass through the request body.
|
|
597
|
-
*
|
|
598
|
-
* @param value - The date to serialise.
|
|
599
|
-
* @param timeZone - IANA timezone name (default: `Europe/Rome`).
|
|
600
|
-
* @returns The naive datetime string, or `undefined` for empty/invalid input.
|
|
601
|
-
*/
|
|
602
|
-
function toLocalDateTimeString(value, timeZone = DEFAULT_TIME_ZONE) {
|
|
603
|
-
if (!value || isNaN(value.getTime()))
|
|
604
|
-
return undefined;
|
|
605
|
-
return format(new TZDate(value.getTime(), timeZone), NAIVE_PATTERN);
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
/**
|
|
609
|
-
* Directive that moves browser focus to the host element after the first render cycle.
|
|
610
|
-
* Apply `autoFocus` to any focusable element to set focus automatically on initialisation.
|
|
611
|
-
*/
|
|
612
|
-
class AutoFocusDirective {
|
|
613
|
-
constructor() {
|
|
614
|
-
this.elementRef = inject(ElementRef);
|
|
615
|
-
afterNextRender(() => {
|
|
616
|
-
this.elementRef.nativeElement?.focus();
|
|
617
|
-
});
|
|
618
|
-
}
|
|
619
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: AutoFocusDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
620
|
-
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.3", type: AutoFocusDirective, isStandalone: true, selector: "[autoFocus]", ngImport: i0 }); }
|
|
621
|
-
}
|
|
622
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: AutoFocusDirective, decorators: [{
|
|
623
|
-
type: Directive,
|
|
624
|
-
args: [{
|
|
625
|
-
selector: '[autoFocus]',
|
|
626
|
-
standalone: true
|
|
627
|
-
}]
|
|
628
|
-
}], ctorParameters: () => [] });
|
|
629
|
-
|
|
630
14
|
class FileInfo {
|
|
631
15
|
isValid() {
|
|
632
16
|
return this.valid;
|
|
@@ -644,6 +28,7 @@ class RelationModel {
|
|
|
644
28
|
}
|
|
645
29
|
class UpdateRelationsModel {
|
|
646
30
|
}
|
|
31
|
+
/** Identifies the external identity provider used for an OAuth login. */
|
|
647
32
|
var LoginOAuthType;
|
|
648
33
|
(function (LoginOAuthType) {
|
|
649
34
|
LoginOAuthType[LoginOAuthType["None"] = 0] = "None";
|
|
@@ -678,564 +63,23 @@ class DateInterval {
|
|
|
678
63
|
return undefined;
|
|
679
64
|
}
|
|
680
65
|
constructor(from, to) {
|
|
681
|
-
this.from = from;
|
|
682
|
-
this.to = to;
|
|
683
|
-
}
|
|
684
|
-
clear() {
|
|
685
|
-
this.from = undefined;
|
|
686
|
-
this.to = undefined;
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
/**
|
|
691
|
-
* Zero-dependency Markdown to HTML converter.
|
|
692
|
-
*
|
|
693
|
-
* Design goals:
|
|
694
|
-
* - Single line-based pass over blocks (no repeated full-string regex passes).
|
|
695
|
-
* - Inline formatting never touches code spans / code blocks (stash & restore).
|
|
696
|
-
* - No HTML entity escaping by default in normal text: the source passes as-is.
|
|
697
|
-
* Exception: the content of code spans / code blocks is ALWAYS escaped, so
|
|
698
|
-
* things like List<string> render correctly (the browser displays the
|
|
699
|
-
* original characters; entities never reach the user or the clipboard).
|
|
700
|
-
* Opt-in full escaping via { escapeHtml: true } for untrusted input.
|
|
701
|
-
* - URL sanitization on links/images (javascript:, vbscript:, data: are dropped).
|
|
702
|
-
* - Bounded result cache for repeated renders (Angular change detection friendly).
|
|
703
|
-
*
|
|
704
|
-
* Supported syntax: headings, paragraphs, hard/soft breaks, hr, blockquotes
|
|
705
|
-
* (nested), fenced code blocks, inline code, bold/italic/strikethrough, links
|
|
706
|
-
* (with title), images, autolinked bare URLs, ordered/unordered lists (nested,
|
|
707
|
-
* task lists, ordered start offset), GFM tables with alignment, raw HTML
|
|
708
|
-
* blocks (e.g. <table>...</table>) passed through verbatim when escapeHtml
|
|
709
|
-
* is false: their newlines stay plain newlines, never converted to <br>.
|
|
710
|
-
*
|
|
711
|
-
* Known simplifications (documented, by design):
|
|
712
|
-
* - A blank line terminates a list.
|
|
713
|
-
* - Setext headings (=== / ---) are not supported, use # syntax.
|
|
714
|
-
* - Reference-style links [text][ref] are not supported.
|
|
715
|
-
*/
|
|
716
|
-
class MarkdownUtils {
|
|
717
|
-
// #region Cache
|
|
718
|
-
static { this.cache = new Map(); }
|
|
719
|
-
static { this.CACHE_MAX = 200; }
|
|
720
|
-
/** Clears the internal result cache. */
|
|
721
|
-
static clearCache() {
|
|
722
|
-
this.cache.clear();
|
|
723
|
-
}
|
|
724
|
-
// #endregion
|
|
725
|
-
// #region Public API
|
|
726
|
-
/**
|
|
727
|
-
* Convert markdown to HTML.
|
|
728
|
-
* @param markdown : the markdown source
|
|
729
|
-
* @param options : conversion options
|
|
730
|
-
* @returns : the HTML string ('' when input is falsy)
|
|
731
|
-
*/
|
|
732
|
-
static toHtml(markdown, options) {
|
|
733
|
-
if (!markdown)
|
|
734
|
-
return '';
|
|
735
|
-
const opts = { escapeHtml: false, breaks: true, ...options };
|
|
736
|
-
const key = `${opts.escapeHtml ? 1 : 0}${opts.breaks ? 1 : 0}|${markdown}`;
|
|
737
|
-
const hit = this.cache.get(key);
|
|
738
|
-
if (hit !== undefined)
|
|
739
|
-
return hit;
|
|
740
|
-
// Normalize line endings and fix glued sentence punctuation ("dettaglio!Ecco").
|
|
741
|
-
// Only when the next char is uppercase (a new sentence), so URLs with query
|
|
742
|
-
// strings ("/page?id=1") and code identifiers are left untouched.
|
|
743
|
-
const source = markdown
|
|
744
|
-
.replace(/\r\n?/g, '\n')
|
|
745
|
-
.replace(/([a-zà-ÿ][!?])([A-ZÀ-Þ])/g, '$1 $2');
|
|
746
|
-
const html = this.parseBlocks(source.split('\n'), opts).trim();
|
|
747
|
-
if (this.cache.size >= this.CACHE_MAX) {
|
|
748
|
-
this.cache.delete(this.cache.keys().next().value); // FIFO eviction
|
|
749
|
-
}
|
|
750
|
-
this.cache.set(key, html);
|
|
751
|
-
return html;
|
|
752
|
-
}
|
|
753
|
-
// #endregion
|
|
754
|
-
// #region Block parser (single pass)
|
|
755
|
-
/** Block-level HTML tags that start a raw HTML block (passthrough, no <p>/<br>). */
|
|
756
|
-
static { this.htmlBlockTags = new Set([
|
|
757
|
-
'address', 'article', 'aside', 'blockquote', 'caption', 'colgroup', 'col',
|
|
758
|
-
'dd', 'details', 'dialog', 'div', 'dl', 'dt', 'fieldset', 'figcaption',
|
|
759
|
-
'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header',
|
|
760
|
-
'hr', 'iframe', 'li', 'main', 'nav', 'ol', 'p', 'pre', 'section', 'summary',
|
|
761
|
-
'table', 'tbody', 'td', 'tfoot', 'thead', 'th', 'tr', 'ul',
|
|
762
|
-
]); }
|
|
763
|
-
/** Void/self-contained tags: a single tag, no closing tag expected. */
|
|
764
|
-
static { this.htmlVoidTags = new Set(['hr', 'col', 'img', 'input']); }
|
|
765
|
-
/**
|
|
766
|
-
* Precompiled: first block-level tag occurrence anywhere in a line.
|
|
767
|
-
* Anchored on the '<' literal, so scanning is cheap; the lookahead
|
|
768
|
-
* prevents partial matches ('<td' must not match inside '<tdx').
|
|
769
|
-
*/
|
|
770
|
-
static { this.htmlBlockScanRe = new RegExp(`<(\\/?)(${[...MarkdownUtils.htmlBlockTags].join('|')})(?=[\\s/>])`, 'gi'); }
|
|
771
|
-
/** Result of scanning a line for the start of a raw HTML block. */
|
|
772
|
-
static findHtmlBlockStart(line) {
|
|
773
|
-
// HTML comment candidate (skipping occurrences inside inline code spans)
|
|
774
|
-
let comment = line.indexOf('<!--');
|
|
775
|
-
while (comment !== -1 && this.insideCodeSpan(line, comment)) {
|
|
776
|
-
comment = line.indexOf('<!--', comment + 4);
|
|
777
|
-
}
|
|
778
|
-
// Block tag candidate (same code-span guard)
|
|
779
|
-
const re = this.htmlBlockScanRe;
|
|
780
|
-
re.lastIndex = 0;
|
|
781
|
-
let m;
|
|
782
|
-
let tagHit;
|
|
783
|
-
while ((m = re.exec(line)) !== null) {
|
|
784
|
-
if (!this.insideCodeSpan(line, m.index)) {
|
|
785
|
-
tagHit = { index: m.index, tag: m[2].toLowerCase(), isClose: m[1] === '/' };
|
|
786
|
-
break;
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
if (comment !== -1 && (!tagHit || comment < tagHit.index)) {
|
|
790
|
-
return { index: comment, tag: '', isClose: false, isComment: true };
|
|
791
|
-
}
|
|
792
|
-
return tagHit ? { ...tagHit, isComment: false } : undefined;
|
|
793
|
-
}
|
|
794
|
-
/** True when `index` falls inside an inline code span (odd backtick count before it). */
|
|
795
|
-
static insideCodeSpan(line, index) {
|
|
796
|
-
let count = 0;
|
|
797
|
-
for (let i = 0; i < index; i++) {
|
|
798
|
-
if (line.charCodeAt(i) === 96 /* ` */)
|
|
799
|
-
count++;
|
|
800
|
-
}
|
|
801
|
-
return (count & 1) === 1;
|
|
802
|
-
}
|
|
803
|
-
/**
|
|
804
|
-
* indexOf-based depth scanner (no regex, no allocations): walks `text`
|
|
805
|
-
* adjusting `depth` for <tag ...> / </tag> occurrences of the SAME tag.
|
|
806
|
-
* Self-closing <tag ... /> forms do not alter depth.
|
|
807
|
-
* @returns [newDepth, endIndex] where endIndex is the position right after
|
|
808
|
-
* the '>' that balanced the element, or -1 when still open.
|
|
809
|
-
*/
|
|
810
|
-
static scanHtmlDepth(text, tag, depth) {
|
|
811
|
-
const lower = text.toLowerCase();
|
|
812
|
-
const open = '<' + tag;
|
|
813
|
-
const close = '</' + tag;
|
|
814
|
-
let i = 0;
|
|
815
|
-
while ((i = lower.indexOf('<', i)) !== -1) {
|
|
816
|
-
if (lower.startsWith(close, i) && this.isTagBoundary(lower, i + close.length)) {
|
|
817
|
-
depth--;
|
|
818
|
-
const gt = lower.indexOf('>', i);
|
|
819
|
-
const end = gt === -1 ? lower.length : gt + 1;
|
|
820
|
-
if (depth <= 0)
|
|
821
|
-
return [0, end];
|
|
822
|
-
i = end;
|
|
823
|
-
}
|
|
824
|
-
else if (lower.startsWith(open, i) && this.isTagBoundary(lower, i + open.length)) {
|
|
825
|
-
const gt = lower.indexOf('>', i);
|
|
826
|
-
const selfClosing = gt !== -1 && lower.charCodeAt(gt - 1) === 47 /* / */;
|
|
827
|
-
if (selfClosing) {
|
|
828
|
-
if (depth === 0)
|
|
829
|
-
return [0, gt + 1]; // standalone self-closed element
|
|
830
|
-
}
|
|
831
|
-
else {
|
|
832
|
-
depth++;
|
|
833
|
-
}
|
|
834
|
-
i = gt === -1 ? lower.length : gt + 1;
|
|
835
|
-
}
|
|
836
|
-
else {
|
|
837
|
-
i++;
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
return [depth, -1];
|
|
841
|
-
}
|
|
842
|
-
/** A tag token must be followed by whitespace, '/', '>' or end of line. */
|
|
843
|
-
static isTagBoundary(s, idx) {
|
|
844
|
-
if (idx >= s.length)
|
|
845
|
-
return true; // tag opening continues on the next line
|
|
846
|
-
const c = s.charCodeAt(idx);
|
|
847
|
-
return c === 62 /* > */ || c === 47 /* / */ || c === 32 /* space */ || c === 9 /* tab */;
|
|
848
|
-
}
|
|
849
|
-
/**
|
|
850
|
-
* Re-injects the remainder of a partially consumed line so the main loop
|
|
851
|
-
* processes it as markdown. Returns the index the loop should resume from.
|
|
852
|
-
*/
|
|
853
|
-
static pushBack(lines, i, remainder) {
|
|
854
|
-
if (remainder.trim()) {
|
|
855
|
-
lines[i] = remainder;
|
|
856
|
-
return i - 1; // the loop's i++ re-processes the remainder
|
|
857
|
-
}
|
|
858
|
-
return i;
|
|
859
|
-
}
|
|
860
|
-
/**
|
|
861
|
-
* Emits a raw HTML block starting at `hit.index` of lines[i], consuming
|
|
862
|
-
* following lines until the element is balanced. Text after the block on
|
|
863
|
-
* the closing line is pushed back for markdown processing.
|
|
864
|
-
* @returns the index of the last consumed line
|
|
865
|
-
*/
|
|
866
|
-
static emitHtmlBlock(lines, i, hit, out) {
|
|
867
|
-
const line = lines[i];
|
|
868
|
-
// HTML comment: raw until '-->'
|
|
869
|
-
if (hit.isComment) {
|
|
870
|
-
let end = line.indexOf('-->', hit.index);
|
|
871
|
-
if (end !== -1) {
|
|
872
|
-
out.push(line.slice(hit.index, end + 3));
|
|
873
|
-
return this.pushBack(lines, i, line.slice(end + 3));
|
|
874
|
-
}
|
|
875
|
-
out.push(line.slice(hit.index));
|
|
876
|
-
while (++i < lines.length) {
|
|
877
|
-
end = lines[i].indexOf('-->');
|
|
878
|
-
if (end !== -1) {
|
|
879
|
-
out.push(lines[i].slice(0, end + 3));
|
|
880
|
-
return this.pushBack(lines, i, lines[i].slice(end + 3));
|
|
881
|
-
}
|
|
882
|
-
out.push(lines[i]);
|
|
883
|
-
}
|
|
884
|
-
return lines.length - 1; // unterminated: consumed to EOF
|
|
885
|
-
}
|
|
886
|
-
// Stray closing tag or void tag: emit just the tag, resume after '>'
|
|
887
|
-
if (hit.isClose || this.htmlVoidTags.has(hit.tag)) {
|
|
888
|
-
const gt = line.indexOf('>', hit.index);
|
|
889
|
-
const end = gt === -1 ? line.length : gt + 1;
|
|
890
|
-
out.push(line.slice(hit.index, end));
|
|
891
|
-
return this.pushBack(lines, i, line.slice(end));
|
|
892
|
-
}
|
|
893
|
-
// Opening block tag: raw until the element is balanced (depth === 0)
|
|
894
|
-
let [depth, split] = this.scanHtmlDepth(line.slice(hit.index), hit.tag, 0);
|
|
895
|
-
if (split !== -1) {
|
|
896
|
-
out.push(line.slice(hit.index, hit.index + split));
|
|
897
|
-
return this.pushBack(lines, i, line.slice(hit.index + split));
|
|
898
|
-
}
|
|
899
|
-
out.push(line.slice(hit.index));
|
|
900
|
-
while (depth > 0 && ++i < lines.length) {
|
|
901
|
-
[depth, split] = this.scanHtmlDepth(lines[i], hit.tag, depth);
|
|
902
|
-
if (split !== -1) {
|
|
903
|
-
out.push(lines[i].slice(0, split));
|
|
904
|
-
return this.pushBack(lines, i, lines[i].slice(split));
|
|
905
|
-
}
|
|
906
|
-
out.push(lines[i]);
|
|
907
|
-
}
|
|
908
|
-
return Math.min(i, lines.length - 1);
|
|
909
|
-
}
|
|
910
|
-
static parseBlocks(lines, opts) {
|
|
911
|
-
const out = [];
|
|
912
|
-
let paragraph = [];
|
|
913
|
-
const flush = () => {
|
|
914
|
-
if (paragraph.length > 0) {
|
|
915
|
-
const sep = opts.breaks ? '<br>' : ' ';
|
|
916
|
-
out.push(`<p>${paragraph.map(l => this.inline(l, opts)).join(sep)}</p>`);
|
|
917
|
-
paragraph = [];
|
|
918
|
-
}
|
|
919
|
-
};
|
|
920
|
-
for (let i = 0; i < lines.length; i++) {
|
|
921
|
-
const line = lines[i];
|
|
922
|
-
const trimmed = line.trim();
|
|
923
|
-
// --- Fenced code block -------------------------------------------------
|
|
924
|
-
const fence = trimmed.match(/^```([\w+-]+)?\s*$/);
|
|
925
|
-
if (fence) {
|
|
926
|
-
flush();
|
|
927
|
-
const lang = fence[1] ? ` class="language-${fence[1]}"` : '';
|
|
928
|
-
const code = [];
|
|
929
|
-
i++;
|
|
930
|
-
while (i < lines.length && !/^```\s*$/.test(lines[i].trim())) {
|
|
931
|
-
code.push(lines[i]);
|
|
932
|
-
i++;
|
|
933
|
-
}
|
|
934
|
-
out.push(`<pre><code${lang}>${this.escape(code.join('\n'))}</code></pre>`);
|
|
935
|
-
continue;
|
|
936
|
-
}
|
|
937
|
-
// --- Blank line --------------------------------------------------------
|
|
938
|
-
if (!trimmed) {
|
|
939
|
-
flush();
|
|
940
|
-
continue;
|
|
941
|
-
}
|
|
942
|
-
// --- Raw HTML block passthrough (only with escaping disabled) ------------
|
|
943
|
-
// A block-level HTML element found ANYWHERE in the line starts a raw
|
|
944
|
-
// block: text before it joins the current paragraph, the element is
|
|
945
|
-
// emitted as-is (plain newlines, no <p>, no <br>) until balanced, and
|
|
946
|
-
// any text after the closing tag resumes markdown processing.
|
|
947
|
-
if (!opts.escapeHtml && line.includes('<')) {
|
|
948
|
-
const hit = this.findHtmlBlockStart(line);
|
|
949
|
-
if (hit) {
|
|
950
|
-
const before = line.slice(0, hit.index).trim();
|
|
951
|
-
if (before)
|
|
952
|
-
paragraph.push(before);
|
|
953
|
-
flush();
|
|
954
|
-
i = this.emitHtmlBlock(lines, i, hit, out);
|
|
955
|
-
continue;
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
// --- ATX heading -------------------------------------------------------
|
|
959
|
-
const heading = trimmed.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
|
|
960
|
-
if (heading) {
|
|
961
|
-
flush();
|
|
962
|
-
const level = heading[1].length;
|
|
963
|
-
out.push(`<h${level}>${this.inline(heading[2], opts)}</h${level}>`);
|
|
964
|
-
continue;
|
|
965
|
-
}
|
|
966
|
-
// --- Horizontal rule ---------------------------------------------------
|
|
967
|
-
if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
|
|
968
|
-
flush();
|
|
969
|
-
out.push('<hr>');
|
|
970
|
-
continue;
|
|
971
|
-
}
|
|
972
|
-
// --- Blockquote (consecutive lines, one level stripped, recursive) ------
|
|
973
|
-
if (/^>\s?/.test(trimmed)) {
|
|
974
|
-
flush();
|
|
975
|
-
const quoted = [];
|
|
976
|
-
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
|
|
977
|
-
quoted.push(lines[i].trim().replace(/^>\s?/, ''));
|
|
978
|
-
i++;
|
|
979
|
-
}
|
|
980
|
-
i--;
|
|
981
|
-
out.push(`<blockquote>${this.parseBlocks(quoted, opts)}</blockquote>`);
|
|
982
|
-
continue;
|
|
983
|
-
}
|
|
984
|
-
// --- Table (header row + separator row) ---------------------------------
|
|
985
|
-
if (trimmed.includes('|') &&
|
|
986
|
-
i + 1 < lines.length &&
|
|
987
|
-
lines[i + 1].includes('-') &&
|
|
988
|
-
/^\|?[\s:\-|]+\|?$/.test(lines[i + 1].trim())) {
|
|
989
|
-
flush();
|
|
990
|
-
const aligns = this.tableAligns(lines[i + 1].trim());
|
|
991
|
-
const head = this.tableRow(trimmed, 'th', aligns, opts);
|
|
992
|
-
const body = [];
|
|
993
|
-
i += 2;
|
|
994
|
-
while (i < lines.length && lines[i].trim().includes('|')) {
|
|
995
|
-
body.push(this.tableRow(lines[i].trim(), 'td', aligns, opts));
|
|
996
|
-
i++;
|
|
997
|
-
}
|
|
998
|
-
i--;
|
|
999
|
-
out.push(`<table>\n<thead>\n${head}\n</thead>` +
|
|
1000
|
-
(body.length > 0 ? `\n<tbody>\n${body.join('\n')}\n</tbody>` : '') +
|
|
1001
|
-
`\n</table>`);
|
|
1002
|
-
continue;
|
|
1003
|
-
}
|
|
1004
|
-
// --- List ---------------------------------------------------------------
|
|
1005
|
-
if (MarkdownUtils.listItemRe.test(line)) {
|
|
1006
|
-
flush();
|
|
1007
|
-
const [html, last] = this.parseList(lines, i, opts);
|
|
1008
|
-
out.push(html);
|
|
1009
|
-
i = last;
|
|
1010
|
-
continue;
|
|
1011
|
-
}
|
|
1012
|
-
// --- Plain text: accumulate into current paragraph ----------------------
|
|
1013
|
-
paragraph.push(trimmed);
|
|
1014
|
-
}
|
|
1015
|
-
flush();
|
|
1016
|
-
return out.join('\n');
|
|
1017
|
-
}
|
|
1018
|
-
// #endregion
|
|
1019
|
-
// #region Lists
|
|
1020
|
-
/** Matches "- item", "* item", "+ item", "1. item", "1) item" with leading indent. */
|
|
1021
|
-
static { this.listItemRe = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/; }
|
|
1022
|
-
/**
|
|
1023
|
-
* Gathers the contiguous list block starting at `start`, builds it (with
|
|
1024
|
-
* nesting) and returns [html, indexOfLastConsumedLine].
|
|
1025
|
-
*/
|
|
1026
|
-
static parseList(lines, start, opts) {
|
|
1027
|
-
const block = [];
|
|
1028
|
-
let i = start;
|
|
1029
|
-
while (i < lines.length) {
|
|
1030
|
-
const l = lines[i];
|
|
1031
|
-
if (!l.trim())
|
|
1032
|
-
break; // blank line ends the list
|
|
1033
|
-
if (this.listItemRe.test(l) || /^\s{2,}\S/.test(l)) {
|
|
1034
|
-
block.push(l);
|
|
1035
|
-
i++;
|
|
1036
|
-
}
|
|
1037
|
-
else
|
|
1038
|
-
break;
|
|
1039
|
-
}
|
|
1040
|
-
return [this.buildList(block, opts), i - 1];
|
|
1041
|
-
}
|
|
1042
|
-
static buildList(block, opts) {
|
|
1043
|
-
const first = block[0].match(this.listItemRe);
|
|
1044
|
-
const baseIndent = first[1].length;
|
|
1045
|
-
const ordered = /^\d/.test(first[2]);
|
|
1046
|
-
const startNum = ordered ? parseInt(first[2], 10) : 1;
|
|
1047
|
-
const items = [];
|
|
1048
|
-
for (const l of block) {
|
|
1049
|
-
const m = l.match(this.listItemRe);
|
|
1050
|
-
if (m && m[1].length <= baseIndent) {
|
|
1051
|
-
items.push({ text: m[3], childLines: [] });
|
|
1052
|
-
}
|
|
1053
|
-
else if (items.length > 0) {
|
|
1054
|
-
// Continuation or nested content: strip one nesting level of indent
|
|
1055
|
-
items[items.length - 1].childLines.push(l.length > baseIndent + 2 ? l.slice(baseIndent + 2) : l.trim());
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
const lis = items.map(item => {
|
|
1059
|
-
let inner;
|
|
1060
|
-
// GFM task list: - [ ] / - [x]
|
|
1061
|
-
const task = item.text.match(/^\[([ xX])\]\s+(.*)$/);
|
|
1062
|
-
if (task) {
|
|
1063
|
-
const checked = task[1] !== ' ' ? ' checked' : '';
|
|
1064
|
-
inner = `<input type="checkbox" disabled${checked}> ${this.inline(task[2], opts)}`;
|
|
1065
|
-
}
|
|
1066
|
-
else {
|
|
1067
|
-
inner = this.inline(item.text, opts);
|
|
1068
|
-
}
|
|
1069
|
-
if (item.childLines.length > 0) {
|
|
1070
|
-
inner += '\n' + this.parseBlocks(item.childLines, opts);
|
|
1071
|
-
}
|
|
1072
|
-
return `<li>${inner}</li>`;
|
|
1073
|
-
});
|
|
1074
|
-
const tag = ordered ? 'ol' : 'ul';
|
|
1075
|
-
const startAttr = ordered && startNum > 1 ? ` start="${startNum}"` : '';
|
|
1076
|
-
return `<${tag}${startAttr}>\n${lis.join('\n')}\n</${tag}>`;
|
|
1077
|
-
}
|
|
1078
|
-
// #endregion
|
|
1079
|
-
// #region Tables
|
|
1080
|
-
static tableAligns(separator) {
|
|
1081
|
-
return separator
|
|
1082
|
-
.replace(/^\||\|$/g, '')
|
|
1083
|
-
.split('|')
|
|
1084
|
-
.map(cell => {
|
|
1085
|
-
const t = cell.trim();
|
|
1086
|
-
if (t.startsWith(':') && t.endsWith(':'))
|
|
1087
|
-
return 'center';
|
|
1088
|
-
if (t.endsWith(':'))
|
|
1089
|
-
return 'right';
|
|
1090
|
-
if (t.startsWith(':'))
|
|
1091
|
-
return 'left';
|
|
1092
|
-
return undefined;
|
|
1093
|
-
});
|
|
1094
|
-
}
|
|
1095
|
-
static tableRow(row, tag, aligns, opts) {
|
|
1096
|
-
const cells = row.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
|
|
1097
|
-
const cellsHtml = cells
|
|
1098
|
-
.map((cell, j) => {
|
|
1099
|
-
const align = aligns[j] ? ` align="${aligns[j]}"` : '';
|
|
1100
|
-
return `<${tag}${align}>${this.inline(cell, opts)}</${tag}>`;
|
|
1101
|
-
})
|
|
1102
|
-
.join('');
|
|
1103
|
-
return `<tr>${cellsHtml}</tr>`;
|
|
1104
|
-
}
|
|
1105
|
-
// #endregion
|
|
1106
|
-
// #region Inline formatting
|
|
1107
|
-
/**
|
|
1108
|
-
* Applies inline markdown to a single text segment.
|
|
1109
|
-
* Generated HTML (code, links, images, autolinks) is stashed behind \u0000
|
|
1110
|
-
* placeholders so later regex passes can never corrupt it.
|
|
1111
|
-
*/
|
|
1112
|
-
static inline(text, opts) {
|
|
1113
|
-
const store = [];
|
|
1114
|
-
const stash = (html) => `\u0000${store.push(html) - 1}\u0000`;
|
|
1115
|
-
// 1. Inline code: stash first so later passes can't touch it.
|
|
1116
|
-
// Content is ALWAYS escaped so <, >, & inside code survive in the DOM
|
|
1117
|
-
// (the browser renders entities back to the original characters).
|
|
1118
|
-
let s = text.replace(/`([^`]+)`/g, (_m, code) => stash(`<code>${this.escape(code)}</code>`));
|
|
1119
|
-
// 2. Escape raw HTML as entities (opt-in only)
|
|
1120
|
-
if (opts.escapeHtml)
|
|
1121
|
-
s = this.escape(s);
|
|
1122
|
-
// 3. Images: 
|
|
1123
|
-
s = s.replace(/!\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+(?:"|")(.+?)(?:"|"))?\s*\)/g, (_m, alt, src, title) => {
|
|
1124
|
-
const url = this.safeUrl(src);
|
|
1125
|
-
if (!url)
|
|
1126
|
-
return alt;
|
|
1127
|
-
const t = title ? ` title="${this.escapeAttr(title)}"` : '';
|
|
1128
|
-
return stash(`<img src="${url}" alt="${this.escapeAttr(alt)}"${t} loading="lazy">`);
|
|
1129
|
-
});
|
|
1130
|
-
// 4. Links: [text](url "title")
|
|
1131
|
-
s = s.replace(/\[([^\]]+)\]\(\s*([^)\s]+)(?:\s+(?:"|")(.+?)(?:"|"))?\s*\)/g, (_m, txt, href, title) => {
|
|
1132
|
-
const url = this.safeUrl(href);
|
|
1133
|
-
if (!url)
|
|
1134
|
-
return txt;
|
|
1135
|
-
const t = title ? ` title="${this.escapeAttr(title)}"` : '';
|
|
1136
|
-
return stash(`<a href="${url}"${t} target="_blank" rel="noopener noreferrer">${txt}</a>`);
|
|
1137
|
-
});
|
|
1138
|
-
// 5. Autolink remaining bare URLs (markdown links are already stashed)
|
|
1139
|
-
s = s.replace(/https?:\/\/[^\s<>"']+/g, url => stash(`<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`));
|
|
1140
|
-
// 6. Bold / italic / strikethrough.
|
|
1141
|
-
// Delimiters must hug the text; underscore emphasis only at word
|
|
1142
|
-
// boundaries so identifiers like snake_case are left untouched.
|
|
1143
|
-
s = s.replace(/\*\*\*(?=\S)([\s\S]*?\S)\*\*\*/g, '<strong><em>$1</em></strong>');
|
|
1144
|
-
s = s.replace(/(?<![\w*])___(?=\S)([\s\S]*?\S)___(?![\w*])/g, '<strong><em>$1</em></strong>');
|
|
1145
|
-
s = s.replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '<strong>$1</strong>');
|
|
1146
|
-
s = s.replace(/(?<![\w*])__(?=\S)([\s\S]*?\S)__(?![\w*])/g, '<strong>$1</strong>');
|
|
1147
|
-
s = s.replace(/\*(?=\S)([^*\n]*?\S)\*/g, '<em>$1</em>');
|
|
1148
|
-
s = s.replace(/(?<![\w_])_(?=\S)([^_\n]*?\S)_(?![\w_])/g, '<em>$1</em>');
|
|
1149
|
-
s = s.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');
|
|
1150
|
-
// 7. Restore stashed HTML
|
|
1151
|
-
return s.replace(/\u0000(\d+)\u0000/g, (_m, idx) => store[Number(idx)] ?? '');
|
|
1152
|
-
}
|
|
1153
|
-
// #endregion
|
|
1154
|
-
// #region Security helpers
|
|
1155
|
-
/** Escapes quotes for safe interpolation inside a double-quoted HTML attribute. */
|
|
1156
|
-
static escapeAttr(s) {
|
|
1157
|
-
return s.replace(/&(?!(?:amp|lt|gt|quot|#\d+);)/g, '&').replace(/"/g, '"');
|
|
1158
|
-
}
|
|
1159
|
-
/** Escapes &, <, > and " for safe HTML interpolation. */
|
|
1160
|
-
static escape(s) {
|
|
1161
|
-
return s
|
|
1162
|
-
.replace(/&/g, '&')
|
|
1163
|
-
.replace(/</g, '<')
|
|
1164
|
-
.replace(/>/g, '>')
|
|
1165
|
-
.replace(/"/g, '"');
|
|
1166
|
-
}
|
|
1167
|
-
/**
|
|
1168
|
-
* Returns a sanitized URL or undefined when the scheme is dangerous.
|
|
1169
|
-
* Blocks javascript:, vbscript: and data: (also when obfuscated with
|
|
1170
|
-
* whitespace/control characters, e.g. "java\tscript:").
|
|
1171
|
-
*/
|
|
1172
|
-
static safeUrl(url) {
|
|
1173
|
-
const compact = url.replace(/[\s\u0000-\u001f]/g, '');
|
|
1174
|
-
if (/^(javascript|vbscript|data):/i.test(compact))
|
|
1175
|
-
return undefined;
|
|
1176
|
-
return url.replace(/"/g, '%22');
|
|
1177
|
-
}
|
|
1178
|
-
// #endregion
|
|
1179
|
-
// #region Clipboard
|
|
1180
|
-
/**
|
|
1181
|
-
* Copies markdown content to the clipboard in two flavors:
|
|
1182
|
-
* - text/html : the rendered HTML (rich paste into Word, Outlook, Gmail...)
|
|
1183
|
-
* - text/plain : the original markdown source (paste into editors, IDEs...)
|
|
1184
|
-
* @param markdown : the markdown source
|
|
1185
|
-
* @param options : conversion options for the HTML flavor
|
|
1186
|
-
* @returns : true on success
|
|
1187
|
-
*/
|
|
1188
|
-
static async copyToClipboard(markdown, options) {
|
|
1189
|
-
if (!markdown || typeof navigator === 'undefined' || !navigator.clipboard)
|
|
1190
|
-
return false;
|
|
1191
|
-
try {
|
|
1192
|
-
if (typeof ClipboardItem !== 'undefined') {
|
|
1193
|
-
const html = this.toHtml(markdown, options);
|
|
1194
|
-
await navigator.clipboard.write([
|
|
1195
|
-
new ClipboardItem({
|
|
1196
|
-
'text/html': new Blob([html], { type: 'text/html' }),
|
|
1197
|
-
'text/plain': new Blob([markdown], { type: 'text/plain' }),
|
|
1198
|
-
}),
|
|
1199
|
-
]);
|
|
1200
|
-
}
|
|
1201
|
-
else {
|
|
1202
|
-
// Older engines: plain text only
|
|
1203
|
-
await navigator.clipboard.writeText(markdown);
|
|
1204
|
-
}
|
|
1205
|
-
return true;
|
|
1206
|
-
}
|
|
1207
|
-
catch {
|
|
1208
|
-
// Clipboard API requires a secure context and user activation
|
|
1209
|
-
return false;
|
|
1210
|
-
}
|
|
1211
|
-
}
|
|
1212
|
-
/**
|
|
1213
|
-
* Copies plain text (e.g. the content of a single code block) to the clipboard.
|
|
1214
|
-
* @param text : the text to copy
|
|
1215
|
-
* @returns : true on success
|
|
1216
|
-
*/
|
|
1217
|
-
static async copyText(text) {
|
|
1218
|
-
if (!text || typeof navigator === 'undefined' || !navigator.clipboard)
|
|
1219
|
-
return false;
|
|
1220
|
-
try {
|
|
1221
|
-
await navigator.clipboard.writeText(text);
|
|
1222
|
-
return true;
|
|
1223
|
-
}
|
|
1224
|
-
catch {
|
|
1225
|
-
return false;
|
|
1226
|
-
}
|
|
66
|
+
this.from = from;
|
|
67
|
+
this.to = to;
|
|
1227
68
|
}
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
* @param element : the element hosting the rendered markdown
|
|
1232
|
-
* @returns : the plain text
|
|
1233
|
-
*/
|
|
1234
|
-
static elementToText(element) {
|
|
1235
|
-
return element.innerText ?? element.textContent ?? '';
|
|
69
|
+
clear() {
|
|
70
|
+
this.from = undefined;
|
|
71
|
+
this.to = undefined;
|
|
1236
72
|
}
|
|
1237
73
|
}
|
|
1238
74
|
|
|
75
|
+
const UtilsMessages = {
|
|
76
|
+
/**
|
|
77
|
+
* Messages
|
|
78
|
+
*/
|
|
79
|
+
// Select dialog
|
|
80
|
+
UTILS_DIALOGS_SELECT_OPTIONS_CHANGED: '§utils-dialogs-select-options-changed'
|
|
81
|
+
};
|
|
82
|
+
|
|
1239
83
|
var DateFormat;
|
|
1240
84
|
(function (DateFormat) {
|
|
1241
85
|
DateFormat[DateFormat["Short"] = 1] = "Short";
|
|
@@ -1504,15 +348,6 @@ class SystemUtils {
|
|
|
1504
348
|
return '';
|
|
1505
349
|
return s.replace(/https?:\/\/[^\s<]+/gi, v => `<a href="${v}" target="_blank" rel="noopener">${v}</a>`);
|
|
1506
350
|
}
|
|
1507
|
-
/**
|
|
1508
|
-
* Convert markdown to html
|
|
1509
|
-
* @param markdown : the markdown data
|
|
1510
|
-
* @param escapeHtml : true to escape HTML. Default is false
|
|
1511
|
-
* @returns the html
|
|
1512
|
-
*/
|
|
1513
|
-
static markdownToHtml(markdown, escapeHtml = false) {
|
|
1514
|
-
return MarkdownUtils.toHtml(markdown, { escapeHtml: escapeHtml });
|
|
1515
|
-
}
|
|
1516
351
|
/**
|
|
1517
352
|
* Compare two names
|
|
1518
353
|
* @param a : name a
|
|
@@ -1902,6 +737,32 @@ class SystemUtils {
|
|
|
1902
737
|
return undefined;
|
|
1903
738
|
return new TZDate(parts[0], parts[1], parts[2], 12);
|
|
1904
739
|
}
|
|
740
|
+
/**
|
|
741
|
+
* Converts a Date, timestamp or ISO string into a Europe/Rome Date whose JSON serialisation
|
|
742
|
+
* emits a naive local datetime string ("yyyy-MM-dd'T'HH:mm:ss"), with no timezone designator,
|
|
743
|
+
* so the wall-clock value round-trips to the server unchanged. Use this instead of `new Date(...)`
|
|
744
|
+
* when reviving dates received from the API, to stay consistent with the DateFnsAdapter (values
|
|
745
|
+
* entered through the datepicker already behave this way).
|
|
746
|
+
* @param value : the source Date, Unix timestamp (ms) or ISO string
|
|
747
|
+
* @returns : a Europe/Rome Date serialising as naive local time, or undefined for empty/invalid input
|
|
748
|
+
*/
|
|
749
|
+
static toLocalDate(value) {
|
|
750
|
+
if (value == null || value === '')
|
|
751
|
+
return undefined;
|
|
752
|
+
const ms = value instanceof Date ? value.getTime()
|
|
753
|
+
: typeof value === 'number' ? value
|
|
754
|
+
: new Date(value).getTime();
|
|
755
|
+
if (isNaN(ms))
|
|
756
|
+
return undefined;
|
|
757
|
+
const t = new TZDate(ms, 'Europe/Rome');
|
|
758
|
+
Object.defineProperty(t, 'toJSON', {
|
|
759
|
+
value: () => format(t, "yyyy-MM-dd'T'HH:mm:ss"),
|
|
760
|
+
enumerable: false,
|
|
761
|
+
configurable: true,
|
|
762
|
+
writable: true,
|
|
763
|
+
});
|
|
764
|
+
return t;
|
|
765
|
+
}
|
|
1905
766
|
/**
|
|
1906
767
|
* Format a date
|
|
1907
768
|
* @param value : the date or string to format
|
|
@@ -1937,32 +798,6 @@ class SystemUtils {
|
|
|
1937
798
|
return format(value, fmt, { locale: locale });
|
|
1938
799
|
}
|
|
1939
800
|
}
|
|
1940
|
-
/**
|
|
1941
|
-
* Converts a Date, timestamp or ISO string into a Europe/Rome Date whose JSON serialisation
|
|
1942
|
-
* emits a naive local datetime string ("yyyy-MM-dd'T'HH:mm:ss"), with no timezone designator,
|
|
1943
|
-
* so the wall-clock value round-trips to the server unchanged. Use this instead of `new Date(...)`
|
|
1944
|
-
* when reviving dates received from the API, to stay consistent with the DateFnsAdapter (values
|
|
1945
|
-
* entered through the datepicker already behave this way).
|
|
1946
|
-
* @param value : the source Date, Unix timestamp (ms) or ISO string
|
|
1947
|
-
* @returns : a Europe/Rome Date serialising as naive local time, or undefined for empty/invalid input
|
|
1948
|
-
*/
|
|
1949
|
-
static toLocalDate(value) {
|
|
1950
|
-
if (value == null || value === '')
|
|
1951
|
-
return undefined;
|
|
1952
|
-
const ms = value instanceof Date ? value.getTime()
|
|
1953
|
-
: typeof value === 'number' ? value
|
|
1954
|
-
: new Date(value).getTime();
|
|
1955
|
-
if (isNaN(ms))
|
|
1956
|
-
return undefined;
|
|
1957
|
-
const t = new TZDate(ms, 'Europe/Rome');
|
|
1958
|
-
Object.defineProperty(t, 'toJSON', {
|
|
1959
|
-
value: () => format(t, "yyyy-MM-dd'T'HH:mm:ss"),
|
|
1960
|
-
enumerable: false,
|
|
1961
|
-
configurable: true,
|
|
1962
|
-
writable: true,
|
|
1963
|
-
});
|
|
1964
|
-
return t;
|
|
1965
|
-
}
|
|
1966
801
|
/**
|
|
1967
802
|
* Update a DateInterval object according to a string
|
|
1968
803
|
* @param value : string value
|
|
@@ -2261,38 +1096,234 @@ class SystemUtils {
|
|
|
2261
1096
|
else
|
|
2262
1097
|
return [];
|
|
2263
1098
|
}
|
|
2264
|
-
/** Cache for resolved color luminance results. */
|
|
2265
|
-
static { this.colorLightCache = new Map(); }
|
|
1099
|
+
/** Cache for resolved color luminance results. */
|
|
1100
|
+
static { this.colorLightCache = new Map(); }
|
|
1101
|
+
/**
|
|
1102
|
+
* Check if a color is light or dark
|
|
1103
|
+
* @param color : the color
|
|
1104
|
+
* @param minimumLuminance : the lumimance to consider
|
|
1105
|
+
* @returns true if the color is light
|
|
1106
|
+
*/
|
|
1107
|
+
static isColorLight(color, minimumLuminance = 186) {
|
|
1108
|
+
if (!this.isBrowser())
|
|
1109
|
+
return true; // SSR fallback
|
|
1110
|
+
const cacheKey = `${color}|${minimumLuminance}`;
|
|
1111
|
+
const cached = this.colorLightCache.get(cacheKey);
|
|
1112
|
+
if (cached !== undefined)
|
|
1113
|
+
return cached;
|
|
1114
|
+
const tempDiv = document.createElement('div');
|
|
1115
|
+
tempDiv.style.color = color;
|
|
1116
|
+
document.body.appendChild(tempDiv);
|
|
1117
|
+
const rgb = getComputedStyle(tempDiv).color;
|
|
1118
|
+
document.body.removeChild(tempDiv);
|
|
1119
|
+
const result = rgb.match(/\d+/g);
|
|
1120
|
+
let light = true; // fallback
|
|
1121
|
+
if (result && result.length >= 3) {
|
|
1122
|
+
const r = parseInt(result[0], 10);
|
|
1123
|
+
const g = parseInt(result[1], 10);
|
|
1124
|
+
const b = parseInt(result[2], 10);
|
|
1125
|
+
light = (0.299 * r + 0.587 * g + 0.114 * b) > minimumLuminance;
|
|
1126
|
+
}
|
|
1127
|
+
this.colorLightCache.set(cacheKey, light);
|
|
1128
|
+
return light;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Generic selection model that tracks a set of selected items identified by a lookup field.
|
|
1134
|
+
* Wraps Angular CDK's `SelectionModel` and adds lookup-based add/remove logic.
|
|
1135
|
+
*
|
|
1136
|
+
* @typeParam T - The item type held in the selection.
|
|
1137
|
+
* @typeParam V - The type of the lookup key used to identify items.
|
|
1138
|
+
*/
|
|
1139
|
+
class SelectableModel {
|
|
1140
|
+
/**
|
|
1141
|
+
* Snapshot of all items currently tracked by this model (selected or previously toggled).
|
|
1142
|
+
* Backed by a signal — reads are always up to date.
|
|
1143
|
+
*/
|
|
1144
|
+
get all() {
|
|
1145
|
+
return this._all();
|
|
1146
|
+
}
|
|
1147
|
+
/** The underlying CDK `SelectionModel` (provides `.selected`, `.isSelected`, etc.). */
|
|
1148
|
+
get current() {
|
|
1149
|
+
return this._current;
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* @param allowMultiSelect - When `true` (default), multiple items can be selected simultaneously.
|
|
1153
|
+
* @param lookupFieldName - Name of the field used as the unique key when searching the internal list
|
|
1154
|
+
* (default: `'id'`). Keys of `T` are autocompleted; plain strings remain accepted.
|
|
1155
|
+
*/
|
|
1156
|
+
constructor(allowMultiSelect = true, lookupFieldName = 'id') {
|
|
1157
|
+
/**
|
|
1158
|
+
* Emits whenever the selection changes.
|
|
1159
|
+
* Carries the affected item, or `undefined` when the whole selection is cleared.
|
|
1160
|
+
*
|
|
1161
|
+
* Note: kept as `EventEmitter` (instead of a plain RxJS `Subject`) for
|
|
1162
|
+
* backward compatibility with existing consumers; `EventEmitter` extends
|
|
1163
|
+
* `Subject`, so `.subscribe()`, `pipe(...)` and `takeUntilDestroyed` all
|
|
1164
|
+
* work as expected.
|
|
1165
|
+
*/
|
|
1166
|
+
this.changed = new EventEmitter();
|
|
1167
|
+
this._all = signal([], /* @ts-ignore */
|
|
1168
|
+
...(ngDevMode ? [{ debugName: "_all" }] : /* istanbul ignore next */ []));
|
|
1169
|
+
/** Signal that is `true` when at least one item is tracked in the internal list. */
|
|
1170
|
+
this.hasValue = computed(() => this._all().length > 0, /* @ts-ignore */
|
|
1171
|
+
...(ngDevMode ? [{ debugName: "hasValue" }] : /* istanbul ignore next */ []));
|
|
1172
|
+
/** Signal with the number of items currently tracked in the internal list. */
|
|
1173
|
+
this.count = computed(() => this._all().length, /* @ts-ignore */
|
|
1174
|
+
...(ngDevMode ? [{ debugName: "count" }] : /* istanbul ignore next */ []));
|
|
1175
|
+
/** Signal that is `true` when exactly one item is currently selected. */
|
|
1176
|
+
this.selectedOne = computed(() => this._all().length === 1, /* @ts-ignore */
|
|
1177
|
+
...(ngDevMode ? [{ debugName: "selectedOne" }] : /* istanbul ignore next */ []));
|
|
1178
|
+
/** Signal that is `true` when two or more items are currently selected. */
|
|
1179
|
+
this.selectedMany = computed(() => this._all().length > 1, /* @ts-ignore */
|
|
1180
|
+
...(ngDevMode ? [{ debugName: "selectedMany" }] : /* istanbul ignore next */ []));
|
|
1181
|
+
/** Signal that is `true` when at least one element is selected. */
|
|
1182
|
+
this.selectedAny = computed(() => this._all().length > 0, /* @ts-ignore */
|
|
1183
|
+
...(ngDevMode ? [{ debugName: "selectedAny" }] : /* istanbul ignore next */ []));
|
|
1184
|
+
/** Signal that is `true` when no item is currently selected. */
|
|
1185
|
+
this.selectedNone = computed(() => this._all().length === 0, /* @ts-ignore */
|
|
1186
|
+
...(ngDevMode ? [{ debugName: "selectedNone" }] : /* istanbul ignore next */ []));
|
|
1187
|
+
/** Signal with the first currently selected item, or `undefined` when none is selected. */
|
|
1188
|
+
this.first = computed(() => this._all()[0], /* @ts-ignore */
|
|
1189
|
+
...(ngDevMode ? [{ debugName: "first" }] : /* istanbul ignore next */ []));
|
|
1190
|
+
this._current = new SelectionModel(allowMultiSelect, [], true,
|
|
1191
|
+
// Compare by lookup key so different instances of the same logical item
|
|
1192
|
+
// (e.g. after a data reload) are treated as the same selection entry.
|
|
1193
|
+
(a, b) => a[lookupFieldName] === b[lookupFieldName]);
|
|
1194
|
+
this._lookupFieldName = lookupFieldName;
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Toggles the CDK selection state of an item that already exists in the tracked list.
|
|
1198
|
+
* Has no effect when `lookupValue` does not match any tracked item.
|
|
1199
|
+
* @param item - The item whose selection state should be toggled.
|
|
1200
|
+
* @param lookupValue - The key value used to locate the item in the internal list.
|
|
1201
|
+
*/
|
|
1202
|
+
updateCurrent(item, lookupValue) {
|
|
1203
|
+
const p = SystemUtils.arrayFindIndexByKey(this._all(), this._lookupFieldName, lookupValue);
|
|
1204
|
+
if (p !== -1) {
|
|
1205
|
+
this._current.toggle(item);
|
|
1206
|
+
this.changed.emit(item);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Toggles an item in both the internal list and the CDK selection.
|
|
1211
|
+
* Adds the item when it is not yet tracked; removes it when it is.
|
|
1212
|
+
* @param item - The item to toggle.
|
|
1213
|
+
* @param lookupValue - The key value used to locate or register the item.
|
|
1214
|
+
*/
|
|
1215
|
+
toggle(item, lookupValue) {
|
|
1216
|
+
if (lookupValue === undefined)
|
|
1217
|
+
return;
|
|
1218
|
+
const p = SystemUtils.arrayFindIndexByKey(this._all(), this._lookupFieldName, lookupValue);
|
|
1219
|
+
if (p === -1) {
|
|
1220
|
+
this._all.update(arr => [...arr, item]);
|
|
1221
|
+
}
|
|
1222
|
+
else {
|
|
1223
|
+
this._all.update(arr => arr.filter((_, i) => i !== p));
|
|
1224
|
+
}
|
|
1225
|
+
this._current.toggle(item);
|
|
1226
|
+
this.changed.emit(item);
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Adds an item to the internal list (when not already present) and marks it as selected.
|
|
1230
|
+
* @param item - The item to select.
|
|
1231
|
+
* @param lookupValue - The key value used to locate or register the item.
|
|
1232
|
+
*/
|
|
1233
|
+
select(item, lookupValue) {
|
|
1234
|
+
if (lookupValue === undefined)
|
|
1235
|
+
return;
|
|
1236
|
+
const p = SystemUtils.arrayFindIndexByKey(this._all(), this._lookupFieldName, lookupValue);
|
|
1237
|
+
if (p === -1) {
|
|
1238
|
+
this._all.update(arr => [...arr, item]);
|
|
1239
|
+
}
|
|
1240
|
+
this._current.select(item);
|
|
1241
|
+
this.changed.emit(item);
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* Removes an item from the internal list and deselects it in the CDK model.
|
|
1245
|
+
* Has no effect when the item is not currently tracked.
|
|
1246
|
+
* @param item - The item to deselect.
|
|
1247
|
+
* @param lookupValue - The key value used to locate the item in the internal list.
|
|
1248
|
+
*/
|
|
1249
|
+
deselect(item, lookupValue) {
|
|
1250
|
+
if (lookupValue === undefined)
|
|
1251
|
+
return;
|
|
1252
|
+
const p = SystemUtils.arrayFindIndexByKey(this._all(), this._lookupFieldName, lookupValue);
|
|
1253
|
+
if (p !== -1) {
|
|
1254
|
+
this._all.update(arr => arr.filter((_, i) => i !== p));
|
|
1255
|
+
this._current.deselect(item);
|
|
1256
|
+
this.changed.emit(item);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
/**
|
|
1260
|
+
* Deselects all items whose lookup key is contained in `lookupValues`.
|
|
1261
|
+
* @param lookupValues - Array of key values identifying the items to deselect.
|
|
1262
|
+
*/
|
|
1263
|
+
deselectByValues(lookupValues) {
|
|
1264
|
+
if (lookupValues.length === 0)
|
|
1265
|
+
return;
|
|
1266
|
+
for (const key of lookupValues) {
|
|
1267
|
+
const p = SystemUtils.arrayFindIndexByKey(this._all(), this._lookupFieldName, key);
|
|
1268
|
+
if (p !== -1) {
|
|
1269
|
+
const item = this._all()[p];
|
|
1270
|
+
this._all.update(arr => arr.filter((_, i) => i !== p));
|
|
1271
|
+
this._current.deselect(item);
|
|
1272
|
+
this.changed.emit(item);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* Runs `clearFunc` against every currently selected item, then clears the CDK selection.
|
|
1278
|
+
* The internal tracked list is **not** affected; only the CDK selection is cleared.
|
|
1279
|
+
* @param clearFunc - Callback invoked for each currently selected item before clearing.
|
|
1280
|
+
*/
|
|
1281
|
+
clearCurrent(clearFunc) {
|
|
1282
|
+
for (const item of this._current.selected) {
|
|
1283
|
+
clearFunc(item);
|
|
1284
|
+
}
|
|
1285
|
+
this._current.clear();
|
|
1286
|
+
this.changed.emit(undefined);
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* Clears both the internal tracked list and the CDK selection.
|
|
1290
|
+
*/
|
|
1291
|
+
clear() {
|
|
1292
|
+
this._all.set([]);
|
|
1293
|
+
this._current.clear();
|
|
1294
|
+
this.changed.emit(undefined);
|
|
1295
|
+
}
|
|
2266
1296
|
/**
|
|
2267
|
-
*
|
|
2268
|
-
* @param
|
|
2269
|
-
* @param minimumLuminance : the lumimance to consider
|
|
2270
|
-
* @returns true if the color is light
|
|
1297
|
+
* Returns `true` when the item identified by `lookupValue` is present in the tracked list.
|
|
1298
|
+
* @param lookupValue - The key value to look up.
|
|
2271
1299
|
*/
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
const g = parseInt(result[1], 10);
|
|
2289
|
-
const b = parseInt(result[2], 10);
|
|
2290
|
-
light = (0.299 * r + 0.587 * g + 0.114 * b) > minimumLuminance;
|
|
2291
|
-
}
|
|
2292
|
-
this.colorLightCache.set(cacheKey, light);
|
|
2293
|
-
return light;
|
|
1300
|
+
isSelected(lookupValue) {
|
|
1301
|
+
return (lookupValue !== undefined &&
|
|
1302
|
+
SystemUtils.arrayFindIndexByKey(this._all(), this._lookupFieldName, lookupValue) !== -1);
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/**
|
|
1307
|
+
* Directive that moves browser focus to the host element after the first render cycle.
|
|
1308
|
+
* Apply `autoFocus` to any focusable element to set focus automatically on initialisation.
|
|
1309
|
+
*/
|
|
1310
|
+
class AutoFocusDirective {
|
|
1311
|
+
constructor() {
|
|
1312
|
+
this.elementRef = inject(ElementRef);
|
|
1313
|
+
afterNextRender(() => {
|
|
1314
|
+
this.elementRef.nativeElement?.focus();
|
|
1315
|
+
});
|
|
2294
1316
|
}
|
|
1317
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: AutoFocusDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
|
|
1318
|
+
static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "22.1.3", type: AutoFocusDirective, isStandalone: true, selector: "[autoFocus]", ngImport: i0 }); }
|
|
2295
1319
|
}
|
|
1320
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: AutoFocusDirective, decorators: [{
|
|
1321
|
+
type: Directive,
|
|
1322
|
+
args: [{
|
|
1323
|
+
selector: '[autoFocus]',
|
|
1324
|
+
standalone: true
|
|
1325
|
+
}]
|
|
1326
|
+
}], ctorParameters: () => [] });
|
|
2296
1327
|
|
|
2297
1328
|
/**
|
|
2298
1329
|
* Directive that listens to `keyup` events on a date input and debounces changes
|
|
@@ -3166,34 +2197,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImpor
|
|
|
3166
2197
|
}]
|
|
3167
2198
|
}] });
|
|
3168
2199
|
|
|
3169
|
-
/**
|
|
3170
|
-
* Pipe that converts a Markdown string to sanitized HTML using `SystemUtils.markdownToHtml`.
|
|
3171
|
-
*
|
|
3172
|
-
* Usage: `{{ text | formatMarkdown }}`
|
|
3173
|
-
*/
|
|
3174
|
-
class FormatMarkdownPipe {
|
|
3175
|
-
constructor() {
|
|
3176
|
-
this.sanitizer = inject(DomSanitizer);
|
|
3177
|
-
}
|
|
3178
|
-
/**
|
|
3179
|
-
* Transforms a Markdown string into sanitized HTML.
|
|
3180
|
-
* @param value - The Markdown input to convert. Treated as an empty string when `undefined`.
|
|
3181
|
-
* @returns A `SafeHtml` value that can be rendered with `[innerHTML]`.
|
|
3182
|
-
*/
|
|
3183
|
-
transform(value) {
|
|
3184
|
-
return this.sanitizer.bypassSecurityTrustHtml(SystemUtils.markdownToHtml(value ?? ''));
|
|
3185
|
-
}
|
|
3186
|
-
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: FormatMarkdownPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
|
|
3187
|
-
static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.1.3", ngImport: i0, type: FormatMarkdownPipe, isStandalone: true, name: "formatMarkdown" }); }
|
|
3188
|
-
}
|
|
3189
|
-
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: FormatMarkdownPipe, decorators: [{
|
|
3190
|
-
type: Pipe,
|
|
3191
|
-
args: [{
|
|
3192
|
-
name: 'formatMarkdown',
|
|
3193
|
-
standalone: true
|
|
3194
|
-
}]
|
|
3195
|
-
}] });
|
|
3196
|
-
|
|
3197
2200
|
/**
|
|
3198
2201
|
* General-purpose formatting pipe that converts a raw value to a locale-aware string
|
|
3199
2202
|
* based on the specified format type.
|
|
@@ -3436,188 +2439,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImpor
|
|
|
3436
2439
|
}]
|
|
3437
2440
|
}] });
|
|
3438
2441
|
|
|
3439
|
-
const UtilsMessages = {
|
|
3440
|
-
/**
|
|
3441
|
-
* Messages
|
|
3442
|
-
*/
|
|
3443
|
-
// Select dialog
|
|
3444
|
-
UTILS_DIALOGS_SELECT_OPTIONS_CHANGED: '§utils-dialogs-select-options-changed'
|
|
3445
|
-
};
|
|
3446
|
-
|
|
3447
|
-
/**
|
|
3448
|
-
* Generic selection model that tracks a set of selected items identified by a lookup field.
|
|
3449
|
-
* Wraps Angular CDK's `SelectionModel` and adds lookup-based add/remove logic.
|
|
3450
|
-
*
|
|
3451
|
-
* @typeParam T - The item type held in the selection.
|
|
3452
|
-
* @typeParam V - The type of the lookup key used to identify items.
|
|
3453
|
-
*/
|
|
3454
|
-
class SelectableModel {
|
|
3455
|
-
/**
|
|
3456
|
-
* Snapshot of all items currently tracked by this model (selected or previously toggled).
|
|
3457
|
-
* Backed by a signal — reads are always up to date.
|
|
3458
|
-
*/
|
|
3459
|
-
get all() {
|
|
3460
|
-
return this._all();
|
|
3461
|
-
}
|
|
3462
|
-
/** The underlying CDK `SelectionModel` (provides `.selected`, `.isSelected`, etc.). */
|
|
3463
|
-
get current() {
|
|
3464
|
-
return this._current;
|
|
3465
|
-
}
|
|
3466
|
-
/**
|
|
3467
|
-
* @param allowMultiSelect - When `true` (default), multiple items can be selected simultaneously.
|
|
3468
|
-
* @param lookupFieldName - Name of the field used as the unique key when searching the internal list
|
|
3469
|
-
* (default: `'id'`). Keys of `T` are autocompleted; plain strings remain accepted.
|
|
3470
|
-
*/
|
|
3471
|
-
constructor(allowMultiSelect = true, lookupFieldName = 'id') {
|
|
3472
|
-
/**
|
|
3473
|
-
* Emits whenever the selection changes.
|
|
3474
|
-
* Carries the affected item, or `undefined` when the whole selection is cleared.
|
|
3475
|
-
*
|
|
3476
|
-
* Note: kept as `EventEmitter` (instead of a plain RxJS `Subject`) for
|
|
3477
|
-
* backward compatibility with existing consumers; `EventEmitter` extends
|
|
3478
|
-
* `Subject`, so `.subscribe()`, `pipe(...)` and `takeUntilDestroyed` all
|
|
3479
|
-
* work as expected.
|
|
3480
|
-
*/
|
|
3481
|
-
this.changed = new EventEmitter();
|
|
3482
|
-
this._all = signal([], /* @ts-ignore */
|
|
3483
|
-
...(ngDevMode ? [{ debugName: "_all" }] : /* istanbul ignore next */ []));
|
|
3484
|
-
/** Signal that is `true` when at least one item is tracked in the internal list. */
|
|
3485
|
-
this.hasValue = computed(() => this._all().length > 0, /* @ts-ignore */
|
|
3486
|
-
...(ngDevMode ? [{ debugName: "hasValue" }] : /* istanbul ignore next */ []));
|
|
3487
|
-
/** Signal with the number of items currently tracked in the internal list. */
|
|
3488
|
-
this.count = computed(() => this._all().length, /* @ts-ignore */
|
|
3489
|
-
...(ngDevMode ? [{ debugName: "count" }] : /* istanbul ignore next */ []));
|
|
3490
|
-
/** Signal that is `true` when exactly one item is currently selected. */
|
|
3491
|
-
this.selectedOne = computed(() => this._all().length === 1, /* @ts-ignore */
|
|
3492
|
-
...(ngDevMode ? [{ debugName: "selectedOne" }] : /* istanbul ignore next */ []));
|
|
3493
|
-
/** Signal that is `true` when two or more items are currently selected. */
|
|
3494
|
-
this.selectedMany = computed(() => this._all().length > 1, /* @ts-ignore */
|
|
3495
|
-
...(ngDevMode ? [{ debugName: "selectedMany" }] : /* istanbul ignore next */ []));
|
|
3496
|
-
/** Signal that is `true` when no item is currently selected. */
|
|
3497
|
-
this.selectedNone = computed(() => this._all().length === 0, /* @ts-ignore */
|
|
3498
|
-
...(ngDevMode ? [{ debugName: "selectedNone" }] : /* istanbul ignore next */ []));
|
|
3499
|
-
/** Signal with the first currently selected item, or `undefined` when none is selected. */
|
|
3500
|
-
this.selectedFirst = computed(() => this._all()[0], /* @ts-ignore */
|
|
3501
|
-
...(ngDevMode ? [{ debugName: "selectedFirst" }] : /* istanbul ignore next */ []));
|
|
3502
|
-
/** Signal with a snapshot of all currently selected items. */
|
|
3503
|
-
this.selectedAll = computed(() => this._all(), /* @ts-ignore */
|
|
3504
|
-
...(ngDevMode ? [{ debugName: "selectedAll" }] : /* istanbul ignore next */ []));
|
|
3505
|
-
this._current = new SelectionModel(allowMultiSelect, [], true,
|
|
3506
|
-
// Compare by lookup key so different instances of the same logical item
|
|
3507
|
-
// (e.g. after a data reload) are treated as the same selection entry.
|
|
3508
|
-
(a, b) => a[lookupFieldName] === b[lookupFieldName]);
|
|
3509
|
-
this._lookupFieldName = lookupFieldName;
|
|
3510
|
-
}
|
|
3511
|
-
/**
|
|
3512
|
-
* Toggles the CDK selection state of an item that already exists in the tracked list.
|
|
3513
|
-
* Has no effect when `lookupValue` does not match any tracked item.
|
|
3514
|
-
* @param item - The item whose selection state should be toggled.
|
|
3515
|
-
* @param lookupValue - The key value used to locate the item in the internal list.
|
|
3516
|
-
*/
|
|
3517
|
-
updateCurrent(item, lookupValue) {
|
|
3518
|
-
const p = SystemUtils.arrayFindIndexByKey(this._all(), this._lookupFieldName, lookupValue);
|
|
3519
|
-
if (p !== -1) {
|
|
3520
|
-
this._current.toggle(item);
|
|
3521
|
-
this.changed.emit(item);
|
|
3522
|
-
}
|
|
3523
|
-
}
|
|
3524
|
-
/**
|
|
3525
|
-
* Toggles an item in both the internal list and the CDK selection.
|
|
3526
|
-
* Adds the item when it is not yet tracked; removes it when it is.
|
|
3527
|
-
* @param item - The item to toggle.
|
|
3528
|
-
* @param lookupValue - The key value used to locate or register the item.
|
|
3529
|
-
*/
|
|
3530
|
-
toggle(item, lookupValue) {
|
|
3531
|
-
if (lookupValue === undefined)
|
|
3532
|
-
return;
|
|
3533
|
-
const p = SystemUtils.arrayFindIndexByKey(this._all(), this._lookupFieldName, lookupValue);
|
|
3534
|
-
if (p === -1) {
|
|
3535
|
-
this._all.update(arr => [...arr, item]);
|
|
3536
|
-
}
|
|
3537
|
-
else {
|
|
3538
|
-
this._all.update(arr => arr.filter((_, i) => i !== p));
|
|
3539
|
-
}
|
|
3540
|
-
this._current.toggle(item);
|
|
3541
|
-
this.changed.emit(item);
|
|
3542
|
-
}
|
|
3543
|
-
/**
|
|
3544
|
-
* Adds an item to the internal list (when not already present) and marks it as selected.
|
|
3545
|
-
* @param item - The item to select.
|
|
3546
|
-
* @param lookupValue - The key value used to locate or register the item.
|
|
3547
|
-
*/
|
|
3548
|
-
select(item, lookupValue) {
|
|
3549
|
-
if (lookupValue === undefined)
|
|
3550
|
-
return;
|
|
3551
|
-
const p = SystemUtils.arrayFindIndexByKey(this._all(), this._lookupFieldName, lookupValue);
|
|
3552
|
-
if (p === -1) {
|
|
3553
|
-
this._all.update(arr => [...arr, item]);
|
|
3554
|
-
}
|
|
3555
|
-
this._current.select(item);
|
|
3556
|
-
this.changed.emit(item);
|
|
3557
|
-
}
|
|
3558
|
-
/**
|
|
3559
|
-
* Removes an item from the internal list and deselects it in the CDK model.
|
|
3560
|
-
* Has no effect when the item is not currently tracked.
|
|
3561
|
-
* @param item - The item to deselect.
|
|
3562
|
-
* @param lookupValue - The key value used to locate the item in the internal list.
|
|
3563
|
-
*/
|
|
3564
|
-
deselect(item, lookupValue) {
|
|
3565
|
-
if (lookupValue === undefined)
|
|
3566
|
-
return;
|
|
3567
|
-
const p = SystemUtils.arrayFindIndexByKey(this._all(), this._lookupFieldName, lookupValue);
|
|
3568
|
-
if (p !== -1) {
|
|
3569
|
-
this._all.update(arr => arr.filter((_, i) => i !== p));
|
|
3570
|
-
this._current.deselect(item);
|
|
3571
|
-
this.changed.emit(item);
|
|
3572
|
-
}
|
|
3573
|
-
}
|
|
3574
|
-
/**
|
|
3575
|
-
* Deselects all items whose lookup key is contained in `lookupValues`.
|
|
3576
|
-
* @param lookupValues - Array of key values identifying the items to deselect.
|
|
3577
|
-
*/
|
|
3578
|
-
deselectByValues(lookupValues) {
|
|
3579
|
-
if (lookupValues.length === 0)
|
|
3580
|
-
return;
|
|
3581
|
-
for (const key of lookupValues) {
|
|
3582
|
-
const p = SystemUtils.arrayFindIndexByKey(this._all(), this._lookupFieldName, key);
|
|
3583
|
-
if (p !== -1) {
|
|
3584
|
-
const item = this._all()[p];
|
|
3585
|
-
this._all.update(arr => arr.filter((_, i) => i !== p));
|
|
3586
|
-
this._current.deselect(item);
|
|
3587
|
-
this.changed.emit(item);
|
|
3588
|
-
}
|
|
3589
|
-
}
|
|
3590
|
-
}
|
|
3591
|
-
/**
|
|
3592
|
-
* Runs `clearFunc` against every currently selected item, then clears the CDK selection.
|
|
3593
|
-
* The internal tracked list is **not** affected; only the CDK selection is cleared.
|
|
3594
|
-
* @param clearFunc - Callback invoked for each currently selected item before clearing.
|
|
3595
|
-
*/
|
|
3596
|
-
clearCurrent(clearFunc) {
|
|
3597
|
-
for (const item of this._current.selected) {
|
|
3598
|
-
clearFunc(item);
|
|
3599
|
-
}
|
|
3600
|
-
this._current.clear();
|
|
3601
|
-
this.changed.emit(undefined);
|
|
3602
|
-
}
|
|
3603
|
-
/**
|
|
3604
|
-
* Clears both the internal tracked list and the CDK selection.
|
|
3605
|
-
*/
|
|
3606
|
-
clear() {
|
|
3607
|
-
this._all.set([]);
|
|
3608
|
-
this._current.clear();
|
|
3609
|
-
this.changed.emit(undefined);
|
|
3610
|
-
}
|
|
3611
|
-
/**
|
|
3612
|
-
* Returns `true` when the item identified by `lookupValue` is present in the tracked list.
|
|
3613
|
-
* @param lookupValue - The key value to look up.
|
|
3614
|
-
*/
|
|
3615
|
-
isSelected(lookupValue) {
|
|
3616
|
-
return (lookupValue !== undefined &&
|
|
3617
|
-
SystemUtils.arrayFindIndexByKey(this._all(), this._lookupFieldName, lookupValue) !== -1);
|
|
3618
|
-
}
|
|
3619
|
-
}
|
|
3620
|
-
|
|
3621
2442
|
/**
|
|
3622
2443
|
* Standard Broadcast Messages Manager
|
|
3623
2444
|
* https://developer.mozilla.org/en-US/docs/Web/API/BroadcastChannel
|
|
@@ -4125,12 +2946,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImpor
|
|
|
4125
2946
|
}], ctorParameters: () => [] });
|
|
4126
2947
|
|
|
4127
2948
|
/*
|
|
4128
|
-
* Public API Surface of
|
|
2949
|
+
* Public API Surface of @arsedizioni/ars-utils/core
|
|
4129
2950
|
*/
|
|
4130
2951
|
|
|
4131
2952
|
/**
|
|
4132
2953
|
* Generated bundle index. Do not edit.
|
|
4133
2954
|
*/
|
|
4134
2955
|
|
|
4135
|
-
export {
|
|
2956
|
+
export { AutoFocusDirective, BroadcastChannelManager, BroadcastService, CHANNEL_NAME, CopyClipboardDirective, DateFormat, DateInterval, DateIntervalChangeDirective, DeleteModel, EmailsValidatorDirective, EnvironmentService, EqualsValidatorDirective, FileInfo, FileSizeValidatorDirective, FormatHtmlPipe, FormatPipe, GroupModel, GuidValidatorDirective, IDModel, ImportModel, LoginOAuthType, MaxTermsValidatorDirective, NotEmptyValidatorDirective, NotEqualValidatorDirective, NotFutureValidatorDirective, PasswordValidatorDirective, QueryModel, RelationModel, RemoveFocusDirective, ReplacePipe, SafeHtmlPipe, SafeUrlPipe, ScreenService, SearchCallbackPipe, SearchFilterPipe, SelectableModel, SplashService, SqlDateValidatorDirective, SystemUtils, ThemeService, TimeValidatorDirective, UpdateRelationsModel, UrlValidatorDirective, UtilsMessages, ValidIfDirective, ValidatorDirective, ValueModel };
|
|
4136
2957
|
//# sourceMappingURL=arsedizioni-ars-utils-core.mjs.map
|