@vaadin/rich-text-editor 24.3.0-alpha1 → 24.3.0-alpha10
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/package.json +15 -12
- package/src/vaadin-rich-text-editor-mixin.d.ts +109 -0
- package/src/vaadin-rich-text-editor-mixin.js +872 -0
- package/src/vaadin-rich-text-editor-styles.js +30 -1
- package/src/vaadin-rich-text-editor.d.ts +4 -93
- package/src/vaadin-rich-text-editor.js +4 -861
- package/theme/lumo/vaadin-rich-text-editor-styles.js +4 -2
- package/vendor/vaadin-quill.js +2 -2
- package/web-types.json +2 -2
- package/web-types.lit.json +2 -2
|
@@ -0,0 +1,872 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright (c) 2000 - 2023 Vaadin Ltd.
|
|
4
|
+
*
|
|
5
|
+
* This program is available under Vaadin Commercial License and Service Terms.
|
|
6
|
+
*
|
|
7
|
+
*
|
|
8
|
+
* See https://vaadin.com/commercial-license-and-service-terms for the full
|
|
9
|
+
* license.
|
|
10
|
+
*/
|
|
11
|
+
import '../vendor/vaadin-quill.js';
|
|
12
|
+
import { timeOut } from '@vaadin/component-base/src/async.js';
|
|
13
|
+
import { isFirefox } from '@vaadin/component-base/src/browser-utils.js';
|
|
14
|
+
import { Debouncer } from '@vaadin/component-base/src/debounce.js';
|
|
15
|
+
|
|
16
|
+
const Quill = window.Quill;
|
|
17
|
+
|
|
18
|
+
// Workaround for text disappearing when accepting spellcheck suggestion
|
|
19
|
+
// See https://github.com/quilljs/quill/issues/2096#issuecomment-399576957
|
|
20
|
+
const Inline = Quill.import('blots/inline');
|
|
21
|
+
|
|
22
|
+
class CustomColor extends Inline {
|
|
23
|
+
constructor(domNode, value) {
|
|
24
|
+
super(domNode, value);
|
|
25
|
+
|
|
26
|
+
// Map <font> properties
|
|
27
|
+
domNode.style.color = domNode.color;
|
|
28
|
+
|
|
29
|
+
const span = this.replaceWith(new Inline(Inline.create()));
|
|
30
|
+
|
|
31
|
+
span.children.forEach((child) => {
|
|
32
|
+
if (child.attributes) child.attributes.copy(span);
|
|
33
|
+
if (child.unwrap) child.unwrap();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
this.remove();
|
|
37
|
+
|
|
38
|
+
return span; // eslint-disable-line no-constructor-return
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
CustomColor.blotName = 'customColor';
|
|
43
|
+
CustomColor.tagName = 'FONT';
|
|
44
|
+
|
|
45
|
+
Quill.register(CustomColor, true);
|
|
46
|
+
|
|
47
|
+
const HANDLERS = [
|
|
48
|
+
'bold',
|
|
49
|
+
'italic',
|
|
50
|
+
'underline',
|
|
51
|
+
'strike',
|
|
52
|
+
'header',
|
|
53
|
+
'script',
|
|
54
|
+
'list',
|
|
55
|
+
'align',
|
|
56
|
+
'blockquote',
|
|
57
|
+
'code-block',
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const SOURCE = {
|
|
61
|
+
API: 'api',
|
|
62
|
+
USER: 'user',
|
|
63
|
+
SILENT: 'silent',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const STATE = {
|
|
67
|
+
DEFAULT: 0,
|
|
68
|
+
FOCUSED: 1,
|
|
69
|
+
CLICKED: 2,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const TAB_KEY = 9;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @polymerMixin
|
|
76
|
+
*/
|
|
77
|
+
export const RichTextEditorMixin = (superClass) =>
|
|
78
|
+
class RichTextEditorMixinClass extends superClass {
|
|
79
|
+
static get properties() {
|
|
80
|
+
return {
|
|
81
|
+
/**
|
|
82
|
+
* Value is a list of the operations which describe change to the document.
|
|
83
|
+
* Each of those operations describe the change at the current index.
|
|
84
|
+
* They can be an `insert`, `delete` or `retain`. The format is as follows:
|
|
85
|
+
*
|
|
86
|
+
* ```js
|
|
87
|
+
* [
|
|
88
|
+
* { insert: 'Hello World' },
|
|
89
|
+
* { insert: '!', attributes: { bold: true }}
|
|
90
|
+
* ]
|
|
91
|
+
* ```
|
|
92
|
+
*
|
|
93
|
+
* See also https://github.com/quilljs/delta for detailed documentation.
|
|
94
|
+
* @type {string}
|
|
95
|
+
*/
|
|
96
|
+
value: {
|
|
97
|
+
type: String,
|
|
98
|
+
notify: true,
|
|
99
|
+
value: '',
|
|
100
|
+
sync: true,
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* HTML representation of the rich text editor content.
|
|
105
|
+
*/
|
|
106
|
+
htmlValue: {
|
|
107
|
+
type: String,
|
|
108
|
+
notify: true,
|
|
109
|
+
readOnly: true,
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* When true, the user can not modify, nor copy the editor content.
|
|
114
|
+
* @type {boolean}
|
|
115
|
+
*/
|
|
116
|
+
disabled: {
|
|
117
|
+
type: Boolean,
|
|
118
|
+
value: false,
|
|
119
|
+
reflectToAttribute: true,
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* When true, the user can not modify the editor content, but can copy it.
|
|
124
|
+
* @type {boolean}
|
|
125
|
+
*/
|
|
126
|
+
readonly: {
|
|
127
|
+
type: Boolean,
|
|
128
|
+
value: false,
|
|
129
|
+
reflectToAttribute: true,
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* An object used to localize this component. The properties are used
|
|
134
|
+
* e.g. as the tooltips for the editor toolbar buttons.
|
|
135
|
+
*
|
|
136
|
+
* @type {!RichTextEditorI18n}
|
|
137
|
+
* @default {English/US}
|
|
138
|
+
*/
|
|
139
|
+
i18n: {
|
|
140
|
+
type: Object,
|
|
141
|
+
value: () => {
|
|
142
|
+
return {
|
|
143
|
+
undo: 'undo',
|
|
144
|
+
redo: 'redo',
|
|
145
|
+
bold: 'bold',
|
|
146
|
+
italic: 'italic',
|
|
147
|
+
underline: 'underline',
|
|
148
|
+
strike: 'strike',
|
|
149
|
+
h1: 'h1',
|
|
150
|
+
h2: 'h2',
|
|
151
|
+
h3: 'h3',
|
|
152
|
+
subscript: 'subscript',
|
|
153
|
+
superscript: 'superscript',
|
|
154
|
+
listOrdered: 'list ordered',
|
|
155
|
+
listBullet: 'list bullet',
|
|
156
|
+
alignLeft: 'align left',
|
|
157
|
+
alignCenter: 'align center',
|
|
158
|
+
alignRight: 'align right',
|
|
159
|
+
image: 'image',
|
|
160
|
+
link: 'link',
|
|
161
|
+
blockquote: 'blockquote',
|
|
162
|
+
codeBlock: 'code block',
|
|
163
|
+
clean: 'clean',
|
|
164
|
+
linkDialogTitle: 'Link address',
|
|
165
|
+
ok: 'OK',
|
|
166
|
+
cancel: 'Cancel',
|
|
167
|
+
remove: 'Remove',
|
|
168
|
+
};
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
/** @private */
|
|
173
|
+
_editor: {
|
|
174
|
+
type: Object,
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Stores old value
|
|
179
|
+
* @private
|
|
180
|
+
*/
|
|
181
|
+
__oldValue: String,
|
|
182
|
+
|
|
183
|
+
/** @private */
|
|
184
|
+
__lastCommittedChange: {
|
|
185
|
+
type: String,
|
|
186
|
+
value: '',
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
/** @private */
|
|
190
|
+
_linkEditing: {
|
|
191
|
+
type: Boolean,
|
|
192
|
+
value: false,
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
/** @private */
|
|
196
|
+
_linkRange: {
|
|
197
|
+
type: Object,
|
|
198
|
+
value: null,
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
/** @private */
|
|
202
|
+
_linkIndex: {
|
|
203
|
+
type: Number,
|
|
204
|
+
value: null,
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
/** @private */
|
|
208
|
+
_linkUrl: {
|
|
209
|
+
type: String,
|
|
210
|
+
value: '',
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
static get observers() {
|
|
216
|
+
return ['_valueChanged(value, _editor)', '_disabledChanged(disabled, readonly, _editor)'];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** @private */
|
|
220
|
+
get _toolbarButtons() {
|
|
221
|
+
return Array.from(this.shadowRoot.querySelectorAll('[part="toolbar"] button')).filter((btn) => {
|
|
222
|
+
return btn.clientHeight > 0;
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* @param {string} prop
|
|
228
|
+
* @param {?string} oldVal
|
|
229
|
+
* @param {?string} newVal
|
|
230
|
+
* @protected
|
|
231
|
+
*/
|
|
232
|
+
attributeChangedCallback(prop, oldVal, newVal) {
|
|
233
|
+
super.attributeChangedCallback(prop, oldVal, newVal);
|
|
234
|
+
|
|
235
|
+
if (prop === 'dir') {
|
|
236
|
+
this.__dir = newVal;
|
|
237
|
+
this.__setDirection(newVal);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** @protected */
|
|
242
|
+
disconnectedCallback() {
|
|
243
|
+
super.disconnectedCallback();
|
|
244
|
+
|
|
245
|
+
// Ensure that htmlValue property set before attach
|
|
246
|
+
// gets applied in case of detach and re-attach.
|
|
247
|
+
if (this.__debounceSetValue && this.__debounceSetValue.isActive()) {
|
|
248
|
+
this.__debounceSetValue.flush();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
this._editor.emitter.removeAllListeners();
|
|
252
|
+
this._editor.emitter.listeners = {};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** @private */
|
|
256
|
+
__setDirection(dir) {
|
|
257
|
+
// Needed for proper `ql-align` class to be set and activate the toolbar align button
|
|
258
|
+
const alignAttributor = Quill.import('attributors/class/align');
|
|
259
|
+
alignAttributor.whitelist = [dir === 'rtl' ? 'left' : 'right', 'center', 'justify'];
|
|
260
|
+
Quill.register(alignAttributor, true);
|
|
261
|
+
|
|
262
|
+
const alignGroup = this._toolbar.querySelector('[part~="toolbar-group-alignment"]');
|
|
263
|
+
|
|
264
|
+
if (dir === 'rtl') {
|
|
265
|
+
alignGroup.querySelector('[part~="toolbar-button-align-left"]').value = 'left';
|
|
266
|
+
alignGroup.querySelector('[part~="toolbar-button-align-right"]').value = '';
|
|
267
|
+
} else {
|
|
268
|
+
alignGroup.querySelector('[part~="toolbar-button-align-left"]').value = '';
|
|
269
|
+
alignGroup.querySelector('[part~="toolbar-button-align-right"]').value = 'right';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
this._editor.getModule('toolbar').update(this._editor.getSelection());
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** @protected */
|
|
276
|
+
async connectedCallback() {
|
|
277
|
+
super.connectedCallback();
|
|
278
|
+
|
|
279
|
+
if (!this.$ && this.updateComplete) {
|
|
280
|
+
await this.updateComplete;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const editor = this.shadowRoot.querySelector('[part="content"]');
|
|
284
|
+
|
|
285
|
+
this._editor = new Quill(editor, {
|
|
286
|
+
modules: {
|
|
287
|
+
toolbar: this._toolbarConfig,
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
this.__patchToolbar();
|
|
292
|
+
this.__patchKeyboard();
|
|
293
|
+
|
|
294
|
+
/* c8 ignore next 3 */
|
|
295
|
+
if (isFirefox) {
|
|
296
|
+
this.__patchFirefoxFocus();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const editorContent = editor.querySelector('.ql-editor');
|
|
300
|
+
|
|
301
|
+
editorContent.setAttribute('role', 'textbox');
|
|
302
|
+
editorContent.setAttribute('aria-multiline', 'true');
|
|
303
|
+
|
|
304
|
+
this._editor.on('text-change', () => {
|
|
305
|
+
const timeout = 200;
|
|
306
|
+
this.__debounceSetValue = Debouncer.debounce(this.__debounceSetValue, timeOut.after(timeout), () => {
|
|
307
|
+
this.value = JSON.stringify(this._editor.getContents().ops);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const TAB_KEY = 9;
|
|
312
|
+
|
|
313
|
+
editorContent.addEventListener('keydown', (e) => {
|
|
314
|
+
if (e.key === 'Escape') {
|
|
315
|
+
if (!this.__tabBindings) {
|
|
316
|
+
this.__tabBindings = this._editor.keyboard.bindings[TAB_KEY];
|
|
317
|
+
this._editor.keyboard.bindings[TAB_KEY] = null;
|
|
318
|
+
}
|
|
319
|
+
} else if (this.__tabBindings) {
|
|
320
|
+
this._editor.keyboard.bindings[TAB_KEY] = this.__tabBindings;
|
|
321
|
+
this.__tabBindings = null;
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
editorContent.addEventListener('blur', () => {
|
|
326
|
+
if (this.__tabBindings) {
|
|
327
|
+
this._editor.keyboard.bindings[TAB_KEY] = this.__tabBindings;
|
|
328
|
+
this.__tabBindings = null;
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
editorContent.addEventListener('focusout', () => {
|
|
333
|
+
if (this._toolbarState === STATE.FOCUSED) {
|
|
334
|
+
this._cleanToolbarState();
|
|
335
|
+
} else {
|
|
336
|
+
this.__emitChangeEvent();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
editorContent.addEventListener('focus', () => {
|
|
341
|
+
// Format changed, but no value changed happened
|
|
342
|
+
if (this._toolbarState === STATE.CLICKED) {
|
|
343
|
+
this._cleanToolbarState();
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
this._editor.on('selection-change', this.__announceFormatting.bind(this));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** @protected */
|
|
351
|
+
ready() {
|
|
352
|
+
super.ready();
|
|
353
|
+
|
|
354
|
+
this._toolbarConfig = this._prepareToolbar();
|
|
355
|
+
this._toolbar = this._toolbarConfig.container;
|
|
356
|
+
|
|
357
|
+
this._addToolbarListeners();
|
|
358
|
+
|
|
359
|
+
requestAnimationFrame(() => {
|
|
360
|
+
this.$.linkDialog.$.dialog.$.overlay.addEventListener('vaadin-overlay-open', () => {
|
|
361
|
+
this.$.linkUrl.focus();
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** @private */
|
|
367
|
+
_prepareToolbar() {
|
|
368
|
+
const clean = Quill.imports['modules/toolbar'].DEFAULTS.handlers.clean;
|
|
369
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
370
|
+
const self = this;
|
|
371
|
+
|
|
372
|
+
const toolbar = {
|
|
373
|
+
container: this.shadowRoot.querySelector('[part="toolbar"]'),
|
|
374
|
+
handlers: {
|
|
375
|
+
clean() {
|
|
376
|
+
self._markToolbarClicked();
|
|
377
|
+
clean.call(this);
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
HANDLERS.forEach((handler) => {
|
|
383
|
+
toolbar.handlers[handler] = (value) => {
|
|
384
|
+
this._markToolbarClicked();
|
|
385
|
+
this._editor.format(handler, value, SOURCE.USER);
|
|
386
|
+
};
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
return toolbar;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** @private */
|
|
393
|
+
_addToolbarListeners() {
|
|
394
|
+
const buttons = this._toolbarButtons;
|
|
395
|
+
const toolbar = this._toolbar;
|
|
396
|
+
|
|
397
|
+
// Disable tabbing to all buttons but the first one
|
|
398
|
+
buttons.forEach((button, index) => index > 0 && button.setAttribute('tabindex', '-1'));
|
|
399
|
+
|
|
400
|
+
toolbar.addEventListener('keydown', (e) => {
|
|
401
|
+
// Use roving tab-index for the toolbar buttons
|
|
402
|
+
if ([37, 39].indexOf(e.keyCode) > -1) {
|
|
403
|
+
e.preventDefault();
|
|
404
|
+
let index = buttons.indexOf(e.target);
|
|
405
|
+
buttons[index].setAttribute('tabindex', '-1');
|
|
406
|
+
|
|
407
|
+
let step;
|
|
408
|
+
if (e.keyCode === 39) {
|
|
409
|
+
step = 1;
|
|
410
|
+
} else if (e.keyCode === 37) {
|
|
411
|
+
step = -1;
|
|
412
|
+
}
|
|
413
|
+
index = (buttons.length + index + step) % buttons.length;
|
|
414
|
+
buttons[index].removeAttribute('tabindex');
|
|
415
|
+
buttons[index].focus();
|
|
416
|
+
}
|
|
417
|
+
// Esc and Tab focuses the content
|
|
418
|
+
if (e.keyCode === 27 || (e.keyCode === TAB_KEY && !e.shiftKey)) {
|
|
419
|
+
e.preventDefault();
|
|
420
|
+
this._editor.focus();
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// Mousedown happens before editor focusout
|
|
425
|
+
toolbar.addEventListener('mousedown', (e) => {
|
|
426
|
+
if (buttons.indexOf(e.composedPath()[0]) > -1) {
|
|
427
|
+
this._markToolbarFocused();
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** @private */
|
|
433
|
+
_markToolbarClicked() {
|
|
434
|
+
this._toolbarState = STATE.CLICKED;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** @private */
|
|
438
|
+
_markToolbarFocused() {
|
|
439
|
+
this._toolbarState = STATE.FOCUSED;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** @private */
|
|
443
|
+
_cleanToolbarState() {
|
|
444
|
+
this._toolbarState = STATE.DEFAULT;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** @private */
|
|
448
|
+
__createFakeFocusTarget() {
|
|
449
|
+
const isRTL = document.documentElement.getAttribute('dir') === 'rtl';
|
|
450
|
+
const elem = document.createElement('textarea');
|
|
451
|
+
// Reset box model
|
|
452
|
+
elem.style.border = '0';
|
|
453
|
+
elem.style.padding = '0';
|
|
454
|
+
elem.style.margin = '0';
|
|
455
|
+
// Move element out of screen horizontally
|
|
456
|
+
elem.style.position = 'absolute';
|
|
457
|
+
elem.style[isRTL ? 'right' : 'left'] = '-9999px';
|
|
458
|
+
// Move element to the same position vertically
|
|
459
|
+
const yPosition = window.pageYOffset || document.documentElement.scrollTop;
|
|
460
|
+
elem.style.top = `${yPosition}px`;
|
|
461
|
+
return elem;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** @private */
|
|
465
|
+
__patchFirefoxFocus() {
|
|
466
|
+
// In Firefox 63+ with native Shadow DOM, when moving focus out of
|
|
467
|
+
// contenteditable and back again within same shadow root, cursor
|
|
468
|
+
// disappears. See https://bugzilla.mozilla.org/show_bug.cgi?id=1496769
|
|
469
|
+
const editorContent = this.shadowRoot.querySelector('.ql-editor');
|
|
470
|
+
let isFake = false;
|
|
471
|
+
|
|
472
|
+
const focusFake = () => {
|
|
473
|
+
isFake = true;
|
|
474
|
+
this.__fakeTarget = this.__createFakeFocusTarget();
|
|
475
|
+
document.body.appendChild(this.__fakeTarget);
|
|
476
|
+
// Let the focus step out of shadow root!
|
|
477
|
+
this.__fakeTarget.focus();
|
|
478
|
+
return new Promise((resolve) => {
|
|
479
|
+
setTimeout(resolve);
|
|
480
|
+
});
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const focusBack = (offsetNode, offset) => {
|
|
484
|
+
this._editor.focus();
|
|
485
|
+
if (offsetNode) {
|
|
486
|
+
this._editor.selection.setNativeRange(offsetNode, offset);
|
|
487
|
+
}
|
|
488
|
+
document.body.removeChild(this.__fakeTarget);
|
|
489
|
+
delete this.__fakeTarget;
|
|
490
|
+
isFake = false;
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
editorContent.addEventListener('mousedown', (e) => {
|
|
494
|
+
if (!this._editor.hasFocus()) {
|
|
495
|
+
const { x, y } = e;
|
|
496
|
+
const { offset, offsetNode } = document.caretPositionFromPoint(x, y);
|
|
497
|
+
focusFake().then(() => {
|
|
498
|
+
focusBack(offsetNode, offset);
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
editorContent.addEventListener('focusin', () => {
|
|
504
|
+
if (isFake === false) {
|
|
505
|
+
focusFake().then(() => focusBack());
|
|
506
|
+
}
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** @private */
|
|
511
|
+
__patchToolbar() {
|
|
512
|
+
const toolbar = this._editor.getModule('toolbar');
|
|
513
|
+
const update = toolbar.update;
|
|
514
|
+
|
|
515
|
+
// Add custom link button to toggle state attribute
|
|
516
|
+
toolbar.controls.push(['link', this.shadowRoot.querySelector('[part~="toolbar-button-link"]')]);
|
|
517
|
+
|
|
518
|
+
toolbar.update = function (range) {
|
|
519
|
+
update.call(toolbar, range);
|
|
520
|
+
|
|
521
|
+
toolbar.controls.forEach((pair) => {
|
|
522
|
+
const input = pair[1];
|
|
523
|
+
const isActive = input.classList.contains('ql-active');
|
|
524
|
+
input.toggleAttribute('on', isActive);
|
|
525
|
+
input.part.toggle('toolbar-button-pressed', isActive);
|
|
526
|
+
});
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** @private */
|
|
531
|
+
__patchKeyboard() {
|
|
532
|
+
const focusToolbar = () => {
|
|
533
|
+
this._markToolbarFocused();
|
|
534
|
+
this._toolbar.querySelector('button:not([tabindex])').focus();
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
const keyboard = this._editor.getModule('keyboard');
|
|
538
|
+
const bindings = keyboard.bindings[TAB_KEY];
|
|
539
|
+
|
|
540
|
+
// Exclude Quill shift-tab bindings, except for code block,
|
|
541
|
+
// as some of those are breaking when on a newline in the list
|
|
542
|
+
// https://github.com/vaadin/vaadin-rich-text-editor/issues/67
|
|
543
|
+
const originalBindings = bindings.filter((b) => !b.shiftKey || (b.format && b.format['code-block']));
|
|
544
|
+
const moveFocusBinding = { key: TAB_KEY, shiftKey: true, handler: focusToolbar };
|
|
545
|
+
|
|
546
|
+
keyboard.bindings[TAB_KEY] = [...originalBindings, moveFocusBinding];
|
|
547
|
+
|
|
548
|
+
// Alt-f10 focuses a toolbar button
|
|
549
|
+
keyboard.addBinding({ key: 121, altKey: true, handler: focusToolbar });
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** @private */
|
|
553
|
+
__emitChangeEvent() {
|
|
554
|
+
let lastCommittedChange = this.__lastCommittedChange;
|
|
555
|
+
|
|
556
|
+
if (this.__debounceSetValue && this.__debounceSetValue.isActive()) {
|
|
557
|
+
lastCommittedChange = this.value;
|
|
558
|
+
this.__debounceSetValue.flush();
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (lastCommittedChange !== this.value) {
|
|
562
|
+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, cancelable: false }));
|
|
563
|
+
this.__lastCommittedChange = this.value;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** @private */
|
|
568
|
+
_onLinkClick() {
|
|
569
|
+
const range = this._editor.getSelection();
|
|
570
|
+
if (range) {
|
|
571
|
+
const LinkBlot = Quill.imports['formats/link'];
|
|
572
|
+
const [link, offset] = this._editor.scroll.descendant(LinkBlot, range.index);
|
|
573
|
+
if (link != null) {
|
|
574
|
+
// Existing link
|
|
575
|
+
this._linkRange = { index: range.index - offset, length: link.length() };
|
|
576
|
+
this._linkUrl = LinkBlot.formats(link.domNode);
|
|
577
|
+
} else if (range.length === 0) {
|
|
578
|
+
this._linkIndex = range.index;
|
|
579
|
+
}
|
|
580
|
+
this._linkEditing = true;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** @private */
|
|
585
|
+
_applyLink(link) {
|
|
586
|
+
if (link) {
|
|
587
|
+
this._markToolbarClicked();
|
|
588
|
+
this._editor.format('link', link, SOURCE.USER);
|
|
589
|
+
this._editor.getModule('toolbar').update(this._editor.selection.savedRange);
|
|
590
|
+
}
|
|
591
|
+
this._closeLinkDialog();
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** @private */
|
|
595
|
+
_insertLink(link, position) {
|
|
596
|
+
if (link) {
|
|
597
|
+
this._markToolbarClicked();
|
|
598
|
+
this._editor.insertText(position, link, { link });
|
|
599
|
+
this._editor.setSelection(position, link.length);
|
|
600
|
+
}
|
|
601
|
+
this._closeLinkDialog();
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** @private */
|
|
605
|
+
_updateLink(link, range) {
|
|
606
|
+
this._markToolbarClicked();
|
|
607
|
+
this._editor.formatText(range, 'link', link, SOURCE.USER);
|
|
608
|
+
this._closeLinkDialog();
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** @private */
|
|
612
|
+
_removeLink() {
|
|
613
|
+
this._markToolbarClicked();
|
|
614
|
+
if (this._linkRange != null) {
|
|
615
|
+
this._editor.formatText(this._linkRange, { link: false, color: false }, SOURCE.USER);
|
|
616
|
+
}
|
|
617
|
+
this._closeLinkDialog();
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** @private */
|
|
621
|
+
_closeLinkDialog() {
|
|
622
|
+
this._linkEditing = false;
|
|
623
|
+
this._linkUrl = '';
|
|
624
|
+
this._linkIndex = null;
|
|
625
|
+
this._linkRange = null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** @private */
|
|
629
|
+
_onLinkEditConfirm() {
|
|
630
|
+
if (this._linkIndex != null) {
|
|
631
|
+
this._insertLink(this._linkUrl, this._linkIndex);
|
|
632
|
+
} else if (this._linkRange) {
|
|
633
|
+
this._updateLink(this._linkUrl, this._linkRange);
|
|
634
|
+
} else {
|
|
635
|
+
this._applyLink(this._linkUrl);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** @private */
|
|
640
|
+
_onLinkEditCancel() {
|
|
641
|
+
this._closeLinkDialog();
|
|
642
|
+
this._editor.focus();
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** @private */
|
|
646
|
+
_onLinkEditRemove() {
|
|
647
|
+
this._removeLink();
|
|
648
|
+
this._closeLinkDialog();
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** @private */
|
|
652
|
+
_onLinkKeydown(e) {
|
|
653
|
+
if (e.keyCode === 13) {
|
|
654
|
+
e.preventDefault();
|
|
655
|
+
e.stopPropagation();
|
|
656
|
+
this.$.confirmLink.click();
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** @private */
|
|
661
|
+
__updateHtmlValue() {
|
|
662
|
+
const editor = this.shadowRoot.querySelector('.ql-editor');
|
|
663
|
+
let content = editor.innerHTML;
|
|
664
|
+
|
|
665
|
+
// Remove Quill classes, e.g. ql-syntax, except for align
|
|
666
|
+
content = content.replace(/\s*ql-(?!align)[\w-]*\s*/gu, '');
|
|
667
|
+
// Remove meta spans, e.g. cursor which are empty after Quill classes removed
|
|
668
|
+
content = content.replace(/<\/?span[^>]*>/gu, '');
|
|
669
|
+
|
|
670
|
+
// Replace Quill align classes with inline styles
|
|
671
|
+
[this.__dir === 'rtl' ? 'left' : 'right', 'center', 'justify'].forEach((align) => {
|
|
672
|
+
content = content.replace(
|
|
673
|
+
new RegExp(` class=[\\\\]?"\\s?ql-align-${align}[\\\\]?"`, 'gu'),
|
|
674
|
+
` style="text-align: ${align}"`,
|
|
675
|
+
);
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
content = content.replace(/ class=""/gu, '');
|
|
679
|
+
|
|
680
|
+
this._setHtmlValue(content);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Sets content represented by HTML snippet into the editor.
|
|
685
|
+
* The snippet is interpreted by [Quill's Clipboard matchers](https://quilljs.com/docs/modules/clipboard/#matchers),
|
|
686
|
+
* which may not produce the exactly input HTML.
|
|
687
|
+
*
|
|
688
|
+
* **NOTE:** Improper handling of HTML can lead to cross site scripting (XSS) and failure to sanitize
|
|
689
|
+
* properly is both notoriously error-prone and a leading cause of web vulnerabilities.
|
|
690
|
+
* This method is aptly named to ensure the developer has taken the necessary precautions.
|
|
691
|
+
* @param {string} htmlValue
|
|
692
|
+
*/
|
|
693
|
+
dangerouslySetHtmlValue(htmlValue) {
|
|
694
|
+
const whitespaceCharacters = {
|
|
695
|
+
'\t': '__VAADIN_RICH_TEXT_EDITOR_TAB',
|
|
696
|
+
' ': '__VAADIN_RICH_TEXT_EDITOR_DOUBLE_SPACE',
|
|
697
|
+
};
|
|
698
|
+
// Replace whitespace characters with placeholders before the Delta conversion to prevent Quill from trimming them
|
|
699
|
+
Object.entries(whitespaceCharacters).forEach(([character, replacement]) => {
|
|
700
|
+
htmlValue = htmlValue.replaceAll(/>[^<]*</gu, (match) => match.replaceAll(character, replacement)); // NOSONAR
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
const deltaFromHtml = this._editor.clipboard.convert(htmlValue);
|
|
704
|
+
|
|
705
|
+
// Restore whitespace characters after the conversion
|
|
706
|
+
Object.entries(whitespaceCharacters).forEach(([character, replacement]) => {
|
|
707
|
+
deltaFromHtml.ops.forEach((op) => {
|
|
708
|
+
if (typeof op.insert === 'string') {
|
|
709
|
+
op.insert = op.insert.replaceAll(replacement, character);
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
this._editor.setContents(deltaFromHtml, SOURCE.API);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** @private */
|
|
718
|
+
__announceFormatting() {
|
|
719
|
+
const timeout = 200;
|
|
720
|
+
|
|
721
|
+
const announcer = this.shadowRoot.querySelector('.announcer');
|
|
722
|
+
announcer.textContent = '';
|
|
723
|
+
|
|
724
|
+
this.__debounceAnnounceFormatting = Debouncer.debounce(
|
|
725
|
+
this.__debounceAnnounceFormatting,
|
|
726
|
+
timeOut.after(timeout),
|
|
727
|
+
() => {
|
|
728
|
+
const formatting = Array.from(this.shadowRoot.querySelectorAll('[part="toolbar"] .ql-active'))
|
|
729
|
+
.map((button) => {
|
|
730
|
+
const tooltip = this.shadowRoot.querySelector(`[for="${button.id}"]`);
|
|
731
|
+
return tooltip.text;
|
|
732
|
+
})
|
|
733
|
+
.join(', ');
|
|
734
|
+
announcer.textContent = formatting;
|
|
735
|
+
},
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** @private */
|
|
740
|
+
_clear() {
|
|
741
|
+
this._editor.deleteText(0, this._editor.getLength(), SOURCE.SILENT);
|
|
742
|
+
this.__updateHtmlValue();
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** @private */
|
|
746
|
+
_undo(e) {
|
|
747
|
+
e.preventDefault();
|
|
748
|
+
this._editor.history.undo();
|
|
749
|
+
this._editor.focus();
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** @private */
|
|
753
|
+
_redo(e) {
|
|
754
|
+
e.preventDefault();
|
|
755
|
+
this._editor.history.redo();
|
|
756
|
+
this._editor.focus();
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** @private */
|
|
760
|
+
_toggleToolbarDisabled(disable) {
|
|
761
|
+
const buttons = this._toolbarButtons;
|
|
762
|
+
if (disable) {
|
|
763
|
+
buttons.forEach((btn) => btn.setAttribute('disabled', 'true'));
|
|
764
|
+
} else {
|
|
765
|
+
buttons.forEach((btn) => btn.removeAttribute('disabled'));
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** @private */
|
|
770
|
+
_onImageTouchEnd(e) {
|
|
771
|
+
// Cancel the event to avoid the following click event
|
|
772
|
+
e.preventDefault();
|
|
773
|
+
this._onImageClick();
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/** @private */
|
|
777
|
+
_onImageClick() {
|
|
778
|
+
this.$.fileInput.value = '';
|
|
779
|
+
this.$.fileInput.click();
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** @private */
|
|
783
|
+
_uploadImage(e) {
|
|
784
|
+
const fileInput = e.target;
|
|
785
|
+
// NOTE: copied from https://github.com/quilljs/quill/blob/1.3.6/themes/base.js#L128
|
|
786
|
+
// needs to be updated in case of switching to Quill 2.0.0
|
|
787
|
+
if (fileInput.files != null && fileInput.files[0] != null) {
|
|
788
|
+
const reader = new FileReader();
|
|
789
|
+
reader.onload = (e) => {
|
|
790
|
+
const image = e.target.result;
|
|
791
|
+
const range = this._editor.getSelection(true);
|
|
792
|
+
this._editor.updateContents(
|
|
793
|
+
new Quill.imports.delta().retain(range.index).delete(range.length).insert({ image }),
|
|
794
|
+
SOURCE.USER,
|
|
795
|
+
);
|
|
796
|
+
this._markToolbarClicked();
|
|
797
|
+
this._editor.setSelection(range.index + 1, SOURCE.SILENT);
|
|
798
|
+
fileInput.value = '';
|
|
799
|
+
};
|
|
800
|
+
reader.readAsDataURL(fileInput.files[0]);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** @private */
|
|
805
|
+
_disabledChanged(disabled, readonly, editor) {
|
|
806
|
+
if (disabled === undefined || readonly === undefined || editor === undefined) {
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if (disabled || readonly) {
|
|
811
|
+
editor.enable(false);
|
|
812
|
+
|
|
813
|
+
if (disabled) {
|
|
814
|
+
this._toggleToolbarDisabled(true);
|
|
815
|
+
}
|
|
816
|
+
} else {
|
|
817
|
+
editor.enable();
|
|
818
|
+
|
|
819
|
+
if (this.__oldDisabled) {
|
|
820
|
+
this._toggleToolbarDisabled(false);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
this.__oldDisabled = disabled;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** @private */
|
|
828
|
+
_valueChanged(value, editor) {
|
|
829
|
+
if (editor === undefined) {
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (value == null || value === '[{"insert":"\\n"}]') {
|
|
834
|
+
this.value = '';
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (value === '') {
|
|
839
|
+
this._clear();
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
let parsedValue;
|
|
844
|
+
try {
|
|
845
|
+
parsedValue = JSON.parse(value);
|
|
846
|
+
if (Array.isArray(parsedValue)) {
|
|
847
|
+
this.__oldValue = value;
|
|
848
|
+
} else {
|
|
849
|
+
throw new Error(`expected JSON string with array of objects, got: ${value}`);
|
|
850
|
+
}
|
|
851
|
+
} catch (err) {
|
|
852
|
+
// Use old value in case new one is not suitable
|
|
853
|
+
this.value = this.__oldValue;
|
|
854
|
+
console.error('Invalid value set to rich-text-editor:', err);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
const delta = new Quill.imports.delta(parsedValue);
|
|
858
|
+
// Suppress text-change event to prevent infinite loop
|
|
859
|
+
if (JSON.stringify(editor.getContents()) !== JSON.stringify(delta)) {
|
|
860
|
+
editor.setContents(delta, SOURCE.SILENT);
|
|
861
|
+
}
|
|
862
|
+
this.__updateHtmlValue();
|
|
863
|
+
|
|
864
|
+
if (this._toolbarState === STATE.CLICKED) {
|
|
865
|
+
this._cleanToolbarState();
|
|
866
|
+
this.__emitChangeEvent();
|
|
867
|
+
} else if (!this._editor.hasFocus()) {
|
|
868
|
+
// Value changed from outside
|
|
869
|
+
this.__lastCommittedChange = this.value;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
};
|