-
-
Notifications
You must be signed in to change notification settings - Fork 45
Expand file tree
/
Copy pathlongTextEditor.ts
More file actions
522 lines (449 loc) · 21.1 KB
/
Copy pathlongTextEditor.ts
File metadata and controls
522 lines (449 loc) · 21.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
import { BindingEventService } from '@slickgrid-universal/binding';
import { createDomElement, getOffset, type HtmlElementPosition, setDeepValue, toSentenceCase } from '@slickgrid-universal/utils';
import { Constants } from './../constants.js';
import type {
Column,
ColumnEditor,
CompositeEditorOption,
Editor,
EditorArguments,
EditorValidator,
EditorValidationResult,
ElementPosition,
GridOption,
Locale,
LongTextEditorOption,
} from '../interfaces/index.js';
import { getDescendantProperty, getTranslationPrefix } from '../services/utilities.js';
import type { TranslaterService } from '../services/translater.service.js';
import { textValidator } from '../editorValidators/textValidator.js';
import { SlickEventData, type SlickGrid } from '../core/index.js';
/*
* An example of a 'detached' editor.
* The UI is added onto document BODY and .position(), .show() and .hide() are implemented.
* KeyDown events are also handled to provide handling for Tab, Shift-Tab, Esc and Ctrl-Enter.
*/
export class LongTextEditor implements Editor {
protected _bindEventService: BindingEventService;
protected _defaultTextValue: any;
protected _isValueTouched = false;
protected _locales: Locale;
protected _timer?: number;
protected _currentLengthElm!: HTMLSpanElement;
protected _textareaElm!: HTMLTextAreaElement;
protected _wrapperElm!: HTMLDivElement;
/** is the Editor disabled? */
disabled = false;
/** SlickGrid Grid object */
grid: SlickGrid;
/** Grid options */
gridOptions: GridOption;
/** The translate library */
protected _translater?: TranslaterService;
constructor(protected readonly args: EditorArguments) {
this.grid = args.grid;
this.gridOptions = args.grid?.getOptions() as GridOption;
const options = this.gridOptions || this.args.column.params || {};
if (options?.translater) {
this._translater = options.translater;
}
// get locales provided by user in forRoot or else use default English locales via the Constants
this._locales = this.gridOptions?.locales || Constants.locales;
this._bindEventService = new BindingEventService();
this.init();
}
/** Get Column Definition object */
get columnDef(): Column {
return this.args.column;
}
/** Get Column Editor object */
get columnEditor(): ColumnEditor {
return this.columnDef?.editor ?? ({} as ColumnEditor);
}
/** Getter for the item data context object */
get dataContext(): any {
return this.args.item;
}
/** Getter for the Editor DOM Element */
get editorDomElement(): HTMLTextAreaElement {
return this._textareaElm;
}
get editorOptions(): LongTextEditorOption {
return { ...this.gridOptions.defaultEditorOptions?.longText, ...this.columnEditor?.editorOptions, ...this.columnEditor?.options };
}
get hasAutoCommitEdit(): boolean {
return this.gridOptions?.autoCommitEdit ?? false;
}
/** Get the Validator function, can be passed in Editor property or Column Definition */
get validator(): EditorValidator | undefined {
return this.columnEditor?.validator ?? this.columnDef?.validator;
}
init(): void {
let cancelText = '';
let saveText = '';
if (this._translater && this._translater.translate && this.gridOptions.enableTranslate) {
const translationPrefix = getTranslationPrefix(this.gridOptions);
const cancelKey = this.editorOptions.buttonTexts?.cancelKey ?? `${translationPrefix}CANCEL`;
const saveKey = this.editorOptions.buttonTexts?.saveKey ?? `${translationPrefix}SAVE`;
cancelText = this._translater.translate(`${translationPrefix}${cancelKey}`);
saveText = this._translater.translate(`${translationPrefix}${saveKey}`);
} else {
cancelText = this.editorOptions.buttonTexts?.cancel ?? this._locales?.TEXT_CANCEL ?? 'Cancel';
saveText = this.editorOptions.buttonTexts?.save ?? this._locales?.TEXT_SAVE ?? 'Save';
}
const compositeEditorOptions = this.args.compositeEditorOptions;
const columnId = this.columnDef?.id ?? '';
const maxLength = this.columnEditor?.maxLength;
const textAreaRows = this.editorOptions?.rows ?? 4;
const containerElm = compositeEditorOptions ? this.args.container : document.body;
this._wrapperElm = createDomElement('div', {
className: `slick-large-editor-text editor-${columnId}`,
style: { position: compositeEditorOptions ? 'relative' : 'absolute' },
});
// add dark mode CSS class when enabled
if (this.gridOptions?.darkMode) {
this._wrapperElm.classList.add('slick-dark-mode');
}
containerElm.appendChild(this._wrapperElm);
// use textarea row if defined but don't go over 3 rows with composite editor modal
this._textareaElm = createDomElement(
'textarea',
{
ariaLabel: this.columnEditor?.ariaLabel ?? `${toSentenceCase(columnId + '')} Text Editor`,
cols: this.editorOptions?.cols ?? 40,
rows: compositeEditorOptions && textAreaRows > 3 ? 3 : textAreaRows,
placeholder: this.columnEditor?.placeholder ?? '',
title: this.columnEditor?.title ?? '',
},
this._wrapperElm
);
const editorFooterElm = createDomElement('div', { className: 'editor-footer' });
const countContainerElm = createDomElement('span', { className: 'counter' });
this._currentLengthElm = createDomElement('span', { className: 'text-length', textContent: '0' });
countContainerElm.appendChild(this._currentLengthElm);
if (maxLength !== undefined) {
countContainerElm.appendChild(createDomElement('span', { className: 'separator', textContent: '/' }));
countContainerElm.appendChild(createDomElement('span', { className: 'max-length', textContent: `${maxLength}` }));
}
editorFooterElm.appendChild(countContainerElm);
if (!compositeEditorOptions) {
const cancelBtnElm = createDomElement('button', { className: 'btn btn-cancel btn-default btn-xs', textContent: cancelText });
const saveBtnElm = createDomElement('button', { className: 'btn btn-save btn-primary btn-xs', textContent: saveText });
if (this.editorOptions.saveButtonPosition === 'left') {
editorFooterElm.appendChild(saveBtnElm);
editorFooterElm.appendChild(cancelBtnElm);
} else {
editorFooterElm.appendChild(cancelBtnElm);
editorFooterElm.appendChild(saveBtnElm);
}
this._bindEventService.bind(cancelBtnElm, 'click', this.cancel.bind(this) as EventListener);
this._bindEventService.bind(saveBtnElm, 'click', this.save.bind(this) as EventListener);
this.position(this.args?.position as ElementPosition);
this._textareaElm.focus();
this._textareaElm.select();
}
this._wrapperElm.appendChild(editorFooterElm);
this._bindEventService.bind(this._textareaElm, 'keydown', this.handleKeyDown.bind(this) as EventListener);
this._bindEventService.bind(this._textareaElm, 'input', this.handleOnInputChange.bind(this) as unknown as EventListener);
this._bindEventService.bind(this._textareaElm, 'paste', this.handleOnInputChange.bind(this) as unknown as EventListener);
}
cancel(): void {
const value = this._defaultTextValue || '';
this._textareaElm.value = value;
this._currentLengthElm.textContent = `${value.length}`;
if (this.args?.cancelChanges) {
this.args.cancelChanges();
}
}
hide(): void {
this._wrapperElm.style.display = 'none';
}
show(): void {
const isCompositeEditor = !!this.args?.compositeEditorOptions;
if (!isCompositeEditor) {
this._wrapperElm.style.display = 'block';
} else {
// when it's a Composite Editor, we'll check if the Editor is editable (by checking onBeforeEditCell) and if not Editable we'll disable the Editor
this.applyInputUsabilityState();
}
}
destroy(): void {
this._bindEventService.unbindAll();
this._wrapperElm?.remove?.();
}
disable(isDisabled = true): void {
const prevIsDisabled = this.disabled;
this.disabled = isDisabled;
if (this._textareaElm && this._wrapperElm) {
if (isDisabled) {
this._textareaElm.disabled = true;
this._wrapperElm.classList.add('disabled');
// clear value when it's newly disabled and not empty
const currentValue = this.getValue();
if (prevIsDisabled !== isDisabled && this.args?.compositeEditorOptions && currentValue !== '') {
this.reset('', true, true);
}
} else {
this._textareaElm.disabled = false;
this._wrapperElm.classList.remove('disabled');
}
}
}
focus(): void {
// always set focus on grid first so that plugin to copy range (SlickCellExternalCopyManager) would still be able to paste at that position
this.grid.focus();
if (this._textareaElm) {
this._textareaElm.focus();
this._textareaElm.select();
}
}
getValue(): string {
return this._textareaElm.value;
}
setValue(val: string, isApplyingValue = false, triggerOnCompositeEditorChange = true): void {
this._textareaElm.value = val;
this._currentLengthElm.textContent = `${val.length}`;
if (isApplyingValue) {
this.applyValue(this.args.item, this.serializeValue());
// if it's set by a Composite Editor, then also trigger a change for it
const compositeEditorOptions = this.args.compositeEditorOptions;
if (compositeEditorOptions && triggerOnCompositeEditorChange) {
this.handleChangeOnCompositeEditor(null, compositeEditorOptions, 'system');
}
}
}
applyValue(item: any, state: any): void {
const fieldName = this.columnDef?.field;
if (fieldName !== undefined) {
const isComplexObject = fieldName?.indexOf('.') > 0; // is the field a complex object, "address.streetNumber"
// validate the value before applying it (if not valid we'll set an empty string)
const validation = this.validate(undefined, state);
const newValue = validation?.valid ? state : '';
// set the new value to the item datacontext
if (isComplexObject) {
// when it's a complex object, user could override the object path (where the editable object is located)
// else we use the path provided in the Field Column Definition
const objectPath = this.columnEditor?.complexObjectPath ?? fieldName ?? '';
setDeepValue(item, objectPath, newValue);
} else {
item[fieldName] = newValue;
}
}
}
isValueChanged(): boolean {
const elmValue = this._textareaElm.value;
return (
!(elmValue === '' && (this._defaultTextValue === null || this._defaultTextValue === undefined)) && elmValue !== this._defaultTextValue
);
}
isValueTouched(): boolean {
return this._isValueTouched;
}
loadValue(item: any): void {
const fieldName = this.columnDef?.field;
if (item && fieldName !== undefined) {
// is the field a complex object, "address.streetNumber"
const isComplexObject = fieldName?.indexOf('.') > 0;
const value = isComplexObject ? getDescendantProperty(item, fieldName) : item[fieldName];
this._defaultTextValue = value || '';
this._textareaElm.value = this._defaultTextValue;
this._currentLengthElm.textContent = this._defaultTextValue.length;
this._textareaElm.defaultValue = this._defaultTextValue;
this._textareaElm.select();
}
}
/**
* Reposition the LongText Editor to be right over the cell, so that it looks like we opened the editor on top of the cell when in reality we just reposition (absolute) over the cell.
* By default we use an "auto" mode which will allow to position the LongText Editor to the best logical position in the window, also when we say position, we are talking about the relative position against the grid cell.
* We can assume that in 80% of the time the default position is bottom right, the default is "auto" but we can also override this and use a specific position.
* Most of the time positioning of the editor will be to the "right" of the cell is ok but if our column is completely on the right side then we'll want to change the position to "left" align.
* Same goes for the top/bottom position, Most of the time positioning the editor to the "bottom" but we are clicking on a cell at the bottom of the grid then we might need to reposition to "top" instead.
* NOTE: this only applies to Inline Editing and will not have any effect when using the Composite Editor modal window.
*/
position(parentPosition: Partial<HtmlElementPosition>): void {
const containerOffset = getOffset(this.args.container);
const containerHeight = this.args.container.offsetHeight;
const containerWidth = this.args.container.offsetWidth;
const calculatedEditorHeight = this._wrapperElm.getBoundingClientRect().height || (this.args.position as ElementPosition).height;
const calculatedEditorWidth = this._wrapperElm.getBoundingClientRect().width || (this.args.position as ElementPosition).width;
const calculatedBodyHeight = document.body.offsetHeight || window.innerHeight; // body height/width might be 0 if so use the window height/width
const calculatedBodyWidth = document.body.offsetWidth || window.innerWidth;
// first defined position will be bottom/right (which will position the editor completely over the cell)
let newPositionTop = this.args.container ? containerOffset.top : (parentPosition.top ?? 0);
let newPositionLeft = this.args.container ? containerOffset.left : (parentPosition.left ?? 0);
// user could explicitely use a "left" position (when user knows his column is completely on the right)
// or when using "auto" and we detect not enough available space then we'll position to the "left" of the cell
const position = this.editorOptions?.position ?? 'auto';
if (position === 'left' || (position === 'auto' && newPositionLeft + calculatedEditorWidth > calculatedBodyWidth)) {
const marginRightAdjustment = this.editorOptions?.marginRight ?? 0;
newPositionLeft -= calculatedEditorWidth - containerWidth + marginRightAdjustment;
}
// do the same calculation/reposition with top/bottom (default is bottom of the cell or in other word starting from the cell going down)
if (position === 'top' || (position === 'auto' && newPositionTop + calculatedEditorHeight > calculatedBodyHeight)) {
newPositionTop -= calculatedEditorHeight - containerHeight;
}
// reposition the editor over the cell (90% of the time this will end up using a position on the "right" of the cell)
this._wrapperElm.style.top = `${newPositionTop}px`;
this._wrapperElm.style.left = `${newPositionLeft}px`;
}
/**
* You can reset or clear the input value,
* when no value is provided it will use the original value to reset (could be useful with Composite Editor Modal with edit/clone)
*/
reset(value?: string, triggerCompositeEventWhenExist = true, clearByDisableCommand = false): void {
const inputValue = value ?? this._defaultTextValue ?? '';
if (this._textareaElm) {
this._defaultTextValue = inputValue;
this._textareaElm.value = inputValue;
this._currentLengthElm.textContent = inputValue.length;
}
this._isValueTouched = false;
const compositeEditorOptions = this.args.compositeEditorOptions;
if (compositeEditorOptions && triggerCompositeEventWhenExist) {
const shouldDeleteFormValue = !clearByDisableCommand;
this.handleChangeOnCompositeEditor(null, compositeEditorOptions, 'user', shouldDeleteFormValue);
}
}
save(): void {
const validation = this.validate();
const isValid = validation?.valid ?? false;
if (this.hasAutoCommitEdit && isValid) {
// do not use args.commitChanges() as this sets the focus to the next row.
// also the select list will stay shown when clicking off the grid
this.grid.getEditorLock().commitCurrentEdit();
} else {
this.args.commitChanges();
}
}
serializeValue(): string {
return this._textareaElm.value;
}
validate(_targetElm?: HTMLElement, inputValue?: any): EditorValidationResult {
// when using Composite Editor, we also want to recheck if the field if disabled/enabled since it might change depending on other inputs on the composite form
if (this.args.compositeEditorOptions) {
this.applyInputUsabilityState();
}
// when field is disabled, we can assume it's valid
if (this.disabled) {
return { valid: true, msg: '' };
}
const elmValue = inputValue !== undefined ? inputValue : this._textareaElm?.value;
return textValidator(elmValue, {
editorArgs: this.args,
errorMessage: this.columnEditor.errorMessage,
minLength: this.columnEditor.minLength,
maxLength: this.columnEditor.maxLength,
operatorConditionalType: this.columnEditor.operatorConditionalType,
required: this.args?.compositeEditorOptions ? false : this.columnEditor.required,
validator: this.validator,
});
}
// --
// protected functions
// ------------------
/** when it's a Composite Editor, we'll check if the Editor is editable (by checking onBeforeEditCell) and if not Editable we'll disable the Editor */
protected applyInputUsabilityState(): void {
const activeCell = this.grid.getActiveCell();
const isCellEditable = this.grid.onBeforeEditCell
.notify({
...activeCell,
item: this.dataContext,
column: this.args.column,
grid: this.grid,
target: 'composite',
compositeEditorOptions: this.args.compositeEditorOptions,
})
.getReturnValue();
this.disable(isCellEditable === false);
}
protected handleKeyDown(e: KeyboardEvent): void {
const key = e.key;
this._isValueTouched = true;
if (!this.args.compositeEditorOptions) {
if ((key === 'Enter' && e.ctrlKey) || (e.ctrlKey && e.key.toUpperCase() === 'S')) {
e.preventDefault();
this.save();
} else if (key === 'Escape') {
e.preventDefault();
this.cancel();
} else if (key === 'Tab') {
e.preventDefault();
e.shiftKey ? this.grid.navigatePrev() : this.grid.navigateNext();
}
}
}
/** On every input change event, we'll update the current text length counter */
protected handleOnInputChange(event: Event & { clipboardData: DataTransfer; target: HTMLTextAreaElement }): void {
const compositeEditorOptions = this.args.compositeEditorOptions;
const maxLength = this.columnEditor?.maxLength;
// when user defines a maxLength, we'll make sure that it doesn't go over this limit if so then truncate the text (disregard the extra text)
let isTruncated = false;
if (maxLength) {
isTruncated = this.truncateText(this._textareaElm, maxLength);
}
// if the text get truncated then update text length as maxLength, else update text length with actual
if (isTruncated) {
this._currentLengthElm.textContent = `${maxLength}`;
} else {
const newText = event.type === 'paste' ? event.clipboardData.getData('text') : event.target.value;
this._currentLengthElm.textContent = `${newText.length}`;
}
// when using a Composite Editor, we'll want to add a debounce delay to avoid perf issue since Composite could affect other editors in the same form
if (compositeEditorOptions) {
const typingDelay = this.gridOptions?.editorTypingDebounce ?? 500;
window.clearTimeout(this._timer);
this._timer = window.setTimeout(() => this.handleChangeOnCompositeEditor(event, compositeEditorOptions), typingDelay);
}
}
protected handleChangeOnCompositeEditor(
event: Event | null,
compositeEditorOptions: CompositeEditorOption,
triggeredBy: 'user' | 'system' = 'user',
isCalledByClearValue = false
): void {
const activeCell = this.grid.getActiveCell();
const column = this.args.column;
const columnId = this.columnDef?.id ?? '';
const item = this.dataContext;
const grid = this.grid;
const newValue = this.serializeValue();
// when valid, we'll also apply the new value to the dataContext item object
if (this.validate().valid) {
this.applyValue(this.dataContext, newValue);
}
this.applyValue(compositeEditorOptions.formValues, newValue);
const isExcludeDisabledFieldFormValues = this.gridOptions?.compositeEditorOptions?.excludeDisabledFieldFormValues ?? false;
if (
isCalledByClearValue ||
(this.disabled && isExcludeDisabledFieldFormValues && compositeEditorOptions.formValues.hasOwnProperty(columnId))
) {
delete compositeEditorOptions.formValues[columnId]; // when the input is disabled we won't include it in the form result object
}
grid.onCompositeEditorChange.notify(
{
...activeCell,
item,
grid,
column,
formValues: compositeEditorOptions.formValues,
editors: compositeEditorOptions.editors,
triggeredBy,
},
new SlickEventData(event)
);
}
/**
* Truncate text if the value is longer than the acceptable max length
* @param inputElm - textarea html element
* @param maxLength - max acceptable length
* @returns truncated - returns True if it truncated or False otherwise
*/
protected truncateText(inputElm: HTMLTextAreaElement, maxLength: number): boolean {
const text = inputElm.value + '';
if (text.length > maxLength) {
inputElm.value = text.substring(0, maxLength);
return true;
}
return false;
}
}