@vaadin/rich-text-editor 24.2.3 → 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.
@@ -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
+ };