/** * @license * Copyright (c) 2000 - 2022 Vaadin Ltd. * * This program is available under Vaadin Commercial License and Service Terms. * * * See https://vaadin.com/commercial-license-and-service-terms for the full * license. */ import '@vaadin/button/src/vaadin-button.js'; import '@vaadin/confirm-dialog/src/vaadin-confirm-dialog.js'; import '@vaadin/text-field/src/vaadin-text-field.js'; import '@vaadin/tooltip/src/vaadin-tooltip.js'; import '../vendor/vaadin-quill.js'; import './vaadin-rich-text-editor-toolbar-styles.js'; import { html, PolymerElement } from '@polymer/polymer/polymer-element.js'; import { timeOut } from '@vaadin/component-base/src/async.js'; import { isFirefox } from '@vaadin/component-base/src/browser-utils.js'; import { Debouncer } from '@vaadin/component-base/src/debounce.js'; import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js'; import { registerStyles, ThemableMixin } from '@vaadin/vaadin-themable-mixin/vaadin-themable-mixin.js'; import { richTextEditorStyles } from './vaadin-rich-text-editor-styles.js'; registerStyles('vaadin-rich-text-editor', richTextEditorStyles, { moduleId: 'vaadin-rich-text-editor-styles' }); const Quill = window.Quill; const HANDLERS = [ 'bold', 'italic', 'underline', 'strike', 'header', 'script', 'list', 'align', 'blockquote', 'code-block', ]; const SOURCE = { API: 'api', USER: 'user', SILENT: 'silent', }; const STATE = { DEFAULT: 0, FOCUSED: 1, CLICKED: 2, }; const TAB_KEY = 9; /** * `<vaadin-rich-text-editor>` is a Web Component for rich text editing. * It provides a set of toolbar controls to apply formatting on the content, * which is stored and can be accessed as HTML5 or JSON string. * * ``` * <vaadin-rich-text-editor></vaadin-rich-text-editor> * ``` * * Vaadin Rich Text Editor focuses on the structure, not the styling of content. * Therefore, the semantic HTML5 tags such as <h1>, <strong> and <ul> are used, * and CSS usage is limited to most common cases, like horizontal text alignment. * * ### Styling * * The following state attributes are available for styling: * * Attribute | Description | Part name * -------------|-------------|------------ * `disabled` | Set to a disabled text editor | :host * `readonly` | Set to a readonly text editor | :host * `on` | Set to a toolbar button applied to the selected text | toolbar-button * * The following shadow DOM parts are available for styling: * * Part name | Description * -------------------------------------|---------------- * `content` | The content wrapper * `toolbar` | The toolbar wrapper * `toolbar-group` | The group for toolbar controls * `toolbar-group-history` | The group for histroy controls * `toolbar-group-emphasis` | The group for emphasis controls * `toolbar-group-heading` | The group for heading controls * `toolbar-group-glyph-transformation` | The group for glyph transformation controls * `toolbar-group-group-list` | The group for group list controls * `toolbar-group-alignment` | The group for alignment controls * `toolbar-group-rich-text` | The group for rich text controls * `toolbar-group-block` | The group for preformatted block controls * `toolbar-group-format` | The group for format controls * `toolbar-button` | The toolbar button (applies to all buttons) * `toolbar-button-undo` | The "undo" button * `toolbar-button-redo` | The "redo" button * `toolbar-button-bold` | The "bold" button * `toolbar-button-italic` | The "italic" button * `toolbar-button-underline` | The "underline" button * `toolbar-button-strike` | The "strike-through" button * `toolbar-button-h1` | The "header 1" button * `toolbar-button-h2` | The "header 2" button * `toolbar-button-h3` | The "header 3" button * `toolbar-button-subscript` | The "subscript" button * `toolbar-button-superscript` | The "superscript" button * `toolbar-button-list-ordered` | The "ordered list" button * `toolbar-button-list-bullet` | The "bullet list" button * `toolbar-button-align-left` | The "left align" button * `toolbar-button-align-center` | The "center align" button * `toolbar-button-align-right` | The "right align" button * `toolbar-button-image` | The "image" button * `toolbar-button-link` | The "link" button * `toolbar-button-blockquote` | The "blockquote" button * `toolbar-button-code-block` | The "code block" button * `toolbar-button-clean` | The "clean formatting" button * * See [Styling Components](https://vaadin.com/docs/latest/styling/custom-theme/styling-components) documentation. * * @fires {Event} change - Fired when the user commits a value change. * @fires {CustomEvent} html-value-changed - Fired when the `htmlValue` property changes. * @fires {CustomEvent} value-changed - Fired when the `value` property changes. * * @extends HTMLElement * @mixes ElementMixin * @mixes ThemableMixin */ class RichTextEditor extends ElementMixin(ThemableMixin(PolymerElement)) { static get template() { return html` <style> :host { display: flex; flex-direction: column; box-sizing: border-box; } :host([hidden]) { display: none !important; } .announcer { position: fixed; clip: rect(0, 0, 0, 0); } input[type='file'] { display: none; } .vaadin-rich-text-editor-container { display: flex; flex-direction: column; min-height: inherit; max-height: inherit; flex: auto; } </style> <div class="vaadin-rich-text-editor-container"> <!-- Create toolbar container --> <div part="toolbar" role="toolbar"> <span part="toolbar-group toolbar-group-history"> <!-- Undo and Redo --> <button id="btn-undo" type="button" part="toolbar-button toolbar-button-undo" on-click="_undo"></button> <vaadin-tooltip for="btn-undo" text="[[i18n.undo]]"></vaadin-tooltip> <button id="btn-redo" type="button" part="toolbar-button toolbar-button-redo" on-click="_redo"></button> <vaadin-tooltip for="btn-redo" text="[[i18n.redo]]"></vaadin-tooltip> </span> <span part="toolbar-group toolbar-group-emphasis"> <!-- Bold --> <button id="btn-bold" class="ql-bold" part="toolbar-button toolbar-button-bold"></button> <vaadin-tooltip for="btn-bold" text="[[i18n.bold]]"></vaadin-tooltip> <!-- Italic --> <button id="btn-italic" class="ql-italic" part="toolbar-button toolbar-button-italic"></button> <vaadin-tooltip for="btn-italic" text="[[i18n.italic]]"></vaadin-tooltip> <!-- Underline --> <button id="btn-underline" class="ql-underline" part="toolbar-button toolbar-button-underline"></button> <vaadin-tooltip for="btn-underline" text="[[i18n.underline]]"></vaadin-tooltip> <!-- Strike --> <button id="btn-strike" class="ql-strike" part="toolbar-button toolbar-button-strike"></button> <vaadin-tooltip for="btn-strike" text="[[i18n.strike]]"></vaadin-tooltip> </span> <span part="toolbar-group toolbar-group-heading"> <!-- Header buttons --> <button id="btn-h1" type="button" class="ql-header" value="1" part="toolbar-button toolbar-button-h1" ></button> <vaadin-tooltip for="btn-h1" text="[[i18n.h1]]"></vaadin-tooltip> <button id="btn-h2" type="button" class="ql-header" value="2" part="toolbar-button toolbar-button-h2" ></button> <vaadin-tooltip for="btn-h2" text="[[i18n.h2]]"></vaadin-tooltip> <button id="btn-h3" type="button" class="ql-header" value="3" part="toolbar-button toolbar-button-h3" ></button> <vaadin-tooltip for="btn-h3" text="[[i18n.h3]]"></vaadin-tooltip> </span> <span part="toolbar-group toolbar-group-glyph-transformation"> <!-- Subscript and superscript --> <button id="btn-subscript" class="ql-script" value="sub" part="toolbar-button toolbar-button-subscript" ></button> <vaadin-tooltip for="btn-subscript" text="[[i18n.subscript]]"></vaadin-tooltip> <button id="btn-superscript" class="ql-script" value="super" part="toolbar-button toolbar-button-superscript" ></button> <vaadin-tooltip for="btn-superscript" text="[[i18n.superscript]]"></vaadin-tooltip> </span> <span part="toolbar-group toolbar-group-list"> <!-- List buttons --> <button id="btn-ol" type="button" class="ql-list" value="ordered" part="toolbar-button toolbar-button-list-ordered" ></button> <vaadin-tooltip for="btn-ol" text="[[i18n.listOrdered]]"></vaadin-tooltip> <button id="btn-ul" type="button" class="ql-list" value="bullet" part="toolbar-button toolbar-button-list-bullet" ></button> <vaadin-tooltip for="btn-ul" text="[[i18n.listBullet]]"></vaadin-tooltip> </span> <span part="toolbar-group toolbar-group-alignment"> <!-- Align buttons --> <button id="btn-left" type="button" class="ql-align" value="" part="toolbar-button toolbar-button-align-left" ></button> <vaadin-tooltip for="btn-left" text="[[i18n.alignLeft]]"></vaadin-tooltip> <button id="btn-center" type="button" class="ql-align" value="center" part="toolbar-button toolbar-button-align-center" ></button> <vaadin-tooltip for="btn-center" text="[[i18n.alignCenter]]"></vaadin-tooltip> <button id="btn-right" type="button" class="ql-align" value="right" part="toolbar-button toolbar-button-align-right" ></button> <vaadin-tooltip for="btn-right" text="[[i18n.alignRight]]"></vaadin-tooltip> </span> <span part="toolbar-group toolbar-group-rich-text"> <!-- Image --> <button id="btn-image" type="button" part="toolbar-button toolbar-button-image" on-touchend="_onImageTouchEnd" on-click="_onImageClick" ></button> <vaadin-tooltip for="btn-image" text="[[i18n.image]]"></vaadin-tooltip> <!-- Link --> <button id="btn-link" type="button" part="toolbar-button toolbar-button-link" on-click="_onLinkClick" ></button> <vaadin-tooltip for="btn-link" text="[[i18n.link]]"></vaadin-tooltip> </span> <span part="toolbar-group toolbar-group-block"> <!-- Blockquote --> <button id="btn-blockquote" type="button" class="ql-blockquote" part="toolbar-button toolbar-button-blockquote" ></button> <vaadin-tooltip for="btn-blockquote" text="[[i18n.blockquote]]"></vaadin-tooltip> <!-- Code block --> <button id="btn-code" type="button" class="ql-code-block" part="toolbar-button toolbar-button-code-block" ></button> <vaadin-tooltip for="btn-code" text="[[i18n.codeBlock]]"></vaadin-tooltip> </span> <span part="toolbar-group toolbar-group-format"> <!-- Clean --> <button id="btn-clean" type="button" class="ql-clean" part="toolbar-button toolbar-button-clean"></button> <vaadin-tooltip for="btn-clean" text="[[i18n.clean]]"></vaadin-tooltip> </span> <input id="fileInput" type="file" accept="image/png, image/gif, image/jpeg, image/bmp, image/x-icon" on-change="_uploadImage" /> </div> <div part="content"></div> <div class="announcer" aria-live="polite"></div> </div> <vaadin-confirm-dialog id="linkDialog" opened="{{_linkEditing}}" header="[[i18n.linkDialogTitle]]"> <vaadin-text-field id="linkUrl" value="{{_linkUrl}}" style="width: 100%;" on-keydown="_onLinkKeydown" ></vaadin-text-field> <vaadin-button id="confirmLink" slot="confirm-button" theme="primary" on-click="_onLinkEditConfirm"> [[i18n.ok]] </vaadin-button> <vaadin-button id="removeLink" slot="reject-button" theme="error" on-click="_onLinkEditRemove" hidden$="[[!_linkRange]]" > [[i18n.remove]] </vaadin-button> <vaadin-button id="cancelLink" slot="cancel-button" on-click="_onLinkEditCancel"> [[i18n.cancel]] </vaadin-button> </vaadin-confirm-dialog> `; } static get is() { return 'vaadin-rich-text-editor'; } static get cvdlName() { return 'vaadin-rich-text-editor'; } static get properties() { return { /** * Value is a list of the operations which describe change to the document. * Each of those operations describe the change at the current index. * They can be an `insert`, `delete` or `retain`. The format is as follows: * * ```js * [ * { insert: 'Hello World' }, * { insert: '!', attributes: { bold: true }} * ] * ``` * * See also https://github.com/quilljs/delta for detailed documentation. * @type {string} */ value: { type: String, notify: true, value: '', }, /** * HTML representation of the rich text editor content. */ htmlValue: { type: String, notify: true, readOnly: true, }, /** * When true, the user can not modify, nor copy the editor content. * @type {boolean} */ disabled: { type: Boolean, value: false, reflectToAttribute: true, }, /** * When true, the user can not modify the editor content, but can copy it. * @type {boolean} */ readonly: { type: Boolean, value: false, reflectToAttribute: true, }, /** * An object used to localize this component. The properties are used * e.g. as the tooltips for the editor toolbar buttons. * * @type {!RichTextEditorI18n} * @default {English/US} */ i18n: { type: Object, value: () => { return { undo: 'undo', redo: 'redo', bold: 'bold', italic: 'italic', underline: 'underline', strike: 'strike', h1: 'h1', h2: 'h2', h3: 'h3', subscript: 'subscript', superscript: 'superscript', listOrdered: 'list ordered', listBullet: 'list bullet', alignLeft: 'align left', alignCenter: 'align center', alignRight: 'align right', image: 'image', link: 'link', blockquote: 'blockquote', codeBlock: 'code block', clean: 'clean', linkDialogTitle: 'Link address', ok: 'OK', cancel: 'Cancel', remove: 'Remove', }; }, }, /** @private */ _editor: { type: Object, }, /** * Stores old value * @private */ __oldValue: String, /** @private */ __lastCommittedChange: { type: String, value: '', }, /** @private */ _linkEditing: { type: Boolean, }, /** @private */ _linkRange: { type: Object, value: null, }, /** @private */ _linkIndex: { type: Number, value: null, }, /** @private */ _linkUrl: { type: String, value: '', }, }; } static get observers() { return ['_valueChanged(value, _editor)', '_disabledChanged(disabled, readonly, _editor)']; } /** * @param {string} prop * @param {?string} oldVal * @param {?string} newVal * @protected */ attributeChangedCallback(prop, oldVal, newVal) { super.attributeChangedCallback(prop, oldVal, newVal); if (prop === 'dir') { this.__dir = newVal; this.__setDirection(newVal); } } /** @protected */ disconnectedCallback() { super.disconnectedCallback(); this._editor.emitter.removeAllListeners(); this._editor.emitter.listeners = {}; } /** @private */ __setDirection(dir) { // Needed for proper `ql-align` class to be set and activate the toolbar align button const alignAttributor = Quill.import('attributors/class/align'); alignAttributor.whitelist = [dir === 'rtl' ? 'left' : 'right', 'center', 'justify']; Quill.register(alignAttributor, true); const alignGroup = this._toolbar.querySelector('[part~="toolbar-group-alignment"]'); if (dir === 'rtl') { alignGroup.querySelector('[part~="toolbar-button-align-left"]').value = 'left'; alignGroup.querySelector('[part~="toolbar-button-align-right"]').value = ''; } else { alignGroup.querySelector('[part~="toolbar-button-align-left"]').value = ''; alignGroup.querySelector('[part~="toolbar-button-align-right"]').value = 'right'; } this._editor.getModule('toolbar').update(this._editor.getSelection()); } /** @protected */ connectedCallback() { super.connectedCallback(); const editor = this.shadowRoot.querySelector('[part="content"]'); this._editor = new Quill(editor, { modules: { toolbar: this._toolbarConfig, }, }); this.__patchToolbar(); this.__patchKeyboard(); /* c8 ignore next 3 */ if (isFirefox) { this.__patchFirefoxFocus(); } const editorContent = editor.querySelector('.ql-editor'); editorContent.setAttribute('role', 'textbox'); editorContent.setAttribute('aria-multiline', 'true'); this._editor.on('text-change', () => { const timeout = 200; this.__debounceSetValue = Debouncer.debounce(this.__debounceSetValue, timeOut.after(timeout), () => { this.value = JSON.stringify(this._editor.getContents().ops); }); }); editorContent.addEventListener('focusout', () => { if (this._toolbarState === STATE.FOCUSED) { this._cleanToolbarState(); } else { this.__emitChangeEvent(); } }); editorContent.addEventListener('focus', () => { // Format changed, but no value changed happened if (this._toolbarState === STATE.CLICKED) { this._cleanToolbarState(); } }); this._editor.on('selection-change', this.__announceFormatting.bind(this)); } /** @protected */ ready() { super.ready(); this._toolbarConfig = this._prepareToolbar(); this._toolbar = this._toolbarConfig.container; this._addToolbarListeners(); this.$.linkDialog.$.dialog.$.overlay.addEventListener('vaadin-overlay-open', () => { this.$.linkUrl.focus(); }); } /** @private */ _prepareToolbar() { const clean = Quill.imports['modules/toolbar'].DEFAULTS.handlers.clean; // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; const toolbar = { container: this.shadowRoot.querySelector('[part="toolbar"]'), handlers: { clean() { self._markToolbarClicked(); clean.call(this); }, }, }; HANDLERS.forEach((handler) => { toolbar.handlers[handler] = (value) => { this._markToolbarClicked(); this._editor.format(handler, value, SOURCE.USER); }; }); return toolbar; } /** @private */ _addToolbarListeners() { const buttons = this._toolbarButtons; const toolbar = this._toolbar; // Disable tabbing to all buttons but the first one buttons.forEach((button, index) => index > 0 && button.setAttribute('tabindex', '-1')); toolbar.addEventListener('keydown', (e) => { // Use roving tab-index for the toolbar buttons if ([37, 39].indexOf(e.keyCode) > -1) { e.preventDefault(); let index = buttons.indexOf(e.target); buttons[index].setAttribute('tabindex', '-1'); let step; if (e.keyCode === 39) { step = 1; } else if (e.keyCode === 37) { step = -1; } index = (buttons.length + index + step) % buttons.length; buttons[index].removeAttribute('tabindex'); buttons[index].focus(); } // Esc and Tab focuses the content if (e.keyCode === 27 || (e.keyCode === TAB_KEY && !e.shiftKey)) { e.preventDefault(); this._editor.focus(); } }); // Mousedown happens before editor focusout toolbar.addEventListener('mousedown', (e) => { if (buttons.indexOf(e.composedPath()[0]) > -1) { this._markToolbarFocused(); } }); } /** @private */ _markToolbarClicked() { this._toolbarState = STATE.CLICKED; } /** @private */ _markToolbarFocused() { this._toolbarState = STATE.FOCUSED; } /** @private */ _cleanToolbarState() { this._toolbarState = STATE.DEFAULT; } /** @private */ __createFakeFocusTarget() { const isRTL = document.documentElement.getAttribute('dir') === 'rtl'; const elem = document.createElement('textarea'); // Reset box model elem.style.border = '0'; elem.style.padding = '0'; elem.style.margin = '0'; // Move element out of screen horizontally elem.style.position = 'absolute'; elem.style[isRTL ? 'right' : 'left'] = '-9999px'; // Move element to the same position vertically const yPosition = window.pageYOffset || document.documentElement.scrollTop; elem.style.top = `${yPosition}px`; return elem; } /** @private */ __patchFirefoxFocus() { // In Firefox 63+ with native Shadow DOM, when moving focus out of // contenteditable and back again within same shadow root, cursor // disappears. See https://bugzilla.mozilla.org/show_bug.cgi?id=1496769 const editorContent = this.shadowRoot.querySelector('.ql-editor'); let isFake = false; const focusFake = () => { isFake = true; this.__fakeTarget = this.__createFakeFocusTarget(); document.body.appendChild(this.__fakeTarget); // Let the focus step out of shadow root! this.__fakeTarget.focus(); return new Promise((resolve) => { setTimeout(resolve); }); }; const focusBack = (offsetNode, offset) => { this._editor.focus(); if (offsetNode) { this._editor.selection.setNativeRange(offsetNode, offset); } document.body.removeChild(this.__fakeTarget); delete this.__fakeTarget; isFake = false; }; editorContent.addEventListener('mousedown', (e) => { if (!this._editor.hasFocus()) { const { x, y } = e; const { offset, offsetNode } = document.caretPositionFromPoint(x, y); focusFake().then(() => { focusBack(offsetNode, offset); }); } }); editorContent.addEventListener('focusin', () => { if (isFake === false) { focusFake().then(() => focusBack()); } }); } /** @private */ __patchToolbar() { const toolbar = this._editor.getModule('toolbar'); const update = toolbar.update; // Add custom link button to toggle state attribute toolbar.controls.push(['link', this.shadowRoot.querySelector('[part~="toolbar-button-link"]')]); toolbar.update = function (range) { update.call(toolbar, range); toolbar.controls.forEach((pair) => { const input = pair[1]; if (input.classList.contains('ql-active')) { input.setAttribute('on', ''); } else { input.removeAttribute('on'); } }); }; } /** @private */ __patchKeyboard() { const focusToolbar = () => { this._markToolbarFocused(); this._toolbar.querySelector('button:not([tabindex])').focus(); }; const keyboard = this._editor.getModule('keyboard'); const bindings = keyboard.bindings[TAB_KEY]; // Exclude Quill shift-tab bindings, except for code block, // as some of those are breaking when on a newline in the list // https://github.com/vaadin/vaadin-rich-text-editor/issues/67 const originalBindings = bindings.filter((b) => !b.shiftKey || (b.format && b.format['code-block'])); const moveFocusBinding = { key: TAB_KEY, shiftKey: true, handler: focusToolbar }; keyboard.bindings[TAB_KEY] = [...originalBindings, moveFocusBinding]; // Alt-f10 focuses a toolbar button keyboard.addBinding({ key: 121, altKey: true, handler: focusToolbar }); } /** @private */ __emitChangeEvent() { let lastCommittedChange = this.__lastCommittedChange; if (this.__debounceSetValue && this.__debounceSetValue.isActive()) { lastCommittedChange = this.value; this.__debounceSetValue.flush(); } if (lastCommittedChange !== this.value) { this.dispatchEvent(new CustomEvent('change', { bubbles: true, cancelable: false })); this.__lastCommittedChange = this.value; } } /** @private */ _onLinkClick() { const range = this._editor.getSelection(); if (range) { const LinkBlot = Quill.imports['formats/link']; const [link, offset] = this._editor.scroll.descendant(LinkBlot, range.index); if (link != null) { // Existing link this._linkRange = { index: range.index - offset, length: link.length() }; this._linkUrl = LinkBlot.formats(link.domNode); } else if (range.length === 0) { this._linkIndex = range.index; } this._linkEditing = true; } } /** @private */ _applyLink(link) { if (link) { this._markToolbarClicked(); this._editor.format('link', link, SOURCE.USER); this._editor.getModule('toolbar').update(this._editor.selection.savedRange); } this._closeLinkDialog(); } /** @private */ _insertLink(link, position) { if (link) { this._markToolbarClicked(); this._editor.insertText(position, link, { link }); this._editor.setSelection(position, link.length); } this._closeLinkDialog(); } /** @private */ _updateLink(link, range) { this._markToolbarClicked(); this._editor.formatText(range, 'link', link, SOURCE.USER); this._closeLinkDialog(); } /** @private */ _removeLink() { this._markToolbarClicked(); if (this._linkRange != null) { this._editor.formatText(this._linkRange, { link: false, color: false }, SOURCE.USER); } this._closeLinkDialog(); } /** @private */ _closeLinkDialog() { this._linkEditing = false; this._linkUrl = ''; this._linkIndex = null; this._linkRange = null; } /** @private */ _onLinkEditConfirm() { if (this._linkIndex != null) { this._insertLink(this._linkUrl, this._linkIndex); } else if (this._linkRange) { this._updateLink(this._linkUrl, this._linkRange); } else { this._applyLink(this._linkUrl); } } /** @private */ _onLinkEditCancel() { this._closeLinkDialog(); this._editor.focus(); } /** @private */ _onLinkEditRemove() { this._removeLink(); this._closeLinkDialog(); } /** @private */ _onLinkKeydown(e) { if (e.keyCode === 13) { e.preventDefault(); e.stopPropagation(); this.$.confirmLink.click(); } } /** @private */ __updateHtmlValue() { const editor = this.shadowRoot.querySelector('.ql-editor'); let content = editor.innerHTML; // Remove Quill classes, e.g. ql-syntax, except for align content = content.replace(/\s*ql-(?!align)[\w-]*\s*/g, ''); // Remove meta spans, e.g. cursor which are empty after Quill classes removed content = content.replace(/<\/?span[^>]*>/gu, ''); // Replace Quill align classes with inline styles [this.__dir === 'rtl' ? 'left' : 'right', 'center', 'justify'].forEach((align) => { content = content.replace( new RegExp(` class=[\\\\]?"\\s?ql-align-${align}[\\\\]?"`, 'g'), ` style="text-align: ${align}"`, ); }); content = content.replace(/ class=""/g, ''); this._setHtmlValue(content); } /** * Sets content represented by HTML snippet into the editor. * The snippet is interpreted by [Quill's Clipboard matchers](https://quilljs.com/docs/modules/clipboard/#matchers), * which may not produce the exactly input HTML. * * **NOTE:** Improper handling of HTML can lead to cross site scripting (XSS) and failure to sanitize * properly is both notoriously error-prone and a leading cause of web vulnerabilities. * This method is aptly named to ensure the developer has taken the necessary precautions. * @param {string} htmlValue */ dangerouslySetHtmlValue(htmlValue) { const deltaFromHtml = this._editor.clipboard.convert(htmlValue); this._editor.setContents(deltaFromHtml, SOURCE.API); } /** @private */ __announceFormatting() { const timeout = 200; const announcer = this.shadowRoot.querySelector('.announcer'); announcer.textContent = ''; this.__debounceAnnounceFormatting = Debouncer.debounce( this.__debounceAnnounceFormatting, timeOut.after(timeout), () => { const formatting = Array.from(this.shadowRoot.querySelectorAll('[part="toolbar"] .ql-active')) .map((button) => { const tooltip = this.shadowRoot.querySelector(`[for="${button.id}"]`); return tooltip.text; }) .join(', '); announcer.textContent = formatting; }, ); } /** @private */ get _toolbarButtons() { return Array.from(this.shadowRoot.querySelectorAll('[part="toolbar"] button')).filter((btn) => { return btn.clientHeight > 0; }); } /** @private */ _clear() { this._editor.deleteText(0, this._editor.getLength(), SOURCE.SILENT); this.__updateHtmlValue(); } /** @private */ _undo(e) { e.preventDefault(); this._editor.history.undo(); this._editor.focus(); } /** @private */ _redo(e) { e.preventDefault(); this._editor.history.redo(); this._editor.focus(); } /** @private */ _toggleToolbarDisabled(disable) { const buttons = this._toolbarButtons; if (disable) { buttons.forEach((btn) => btn.setAttribute('disabled', 'true')); } else { buttons.forEach((btn) => btn.removeAttribute('disabled')); } } /** @private */ _onImageTouchEnd(e) { // Cancel the event to avoid the following click event e.preventDefault(); this._onImageClick(); } /** @private */ _onImageClick() { this.$.fileInput.value = ''; this.$.fileInput.click(); } /** @private */ _uploadImage(e) { const fileInput = e.target; // NOTE: copied from https://github.com/quilljs/quill/blob/1.3.6/themes/base.js#L128 // needs to be updated in case of switching to Quill 2.0.0 if (fileInput.files != null && fileInput.files[0] != null) { const reader = new FileReader(); reader.onload = (e) => { const image = e.target.result; const range = this._editor.getSelection(true); this._editor.updateContents( new Quill.imports.delta().retain(range.index).delete(range.length).insert({ image }), SOURCE.USER, ); this._markToolbarClicked(); this._editor.setSelection(range.index + 1, SOURCE.SILENT); fileInput.value = ''; }; reader.readAsDataURL(fileInput.files[0]); } } /** @private */ _disabledChanged(disabled, readonly, editor) { if (disabled === undefined || readonly === undefined || editor === undefined) { return; } if (disabled || readonly) { editor.enable(false); if (disabled) { this._toggleToolbarDisabled(true); } } else { editor.enable(); if (this.__oldDisabled) { this._toggleToolbarDisabled(false); } } this.__oldDisabled = disabled; } /** @private */ _valueChanged(value, editor) { if (editor === undefined) { return; } if (value == null || value === '[{"insert":"\\n"}]') { this.value = ''; return; } if (value === '') { this._clear(); return; } let parsedValue; try { parsedValue = JSON.parse(value); if (Array.isArray(parsedValue)) { this.__oldValue = value; } else { throw new Error(`expected JSON string with array of objects, got: ${value}`); } } catch (err) { // Use old value in case new one is not suitable this.value = this.__oldValue; console.error('Invalid value set to rich-text-editor:', err); return; } const delta = new Quill.imports.delta(parsedValue); // Suppress text-change event to prevent infinite loop if (JSON.stringify(editor.getContents()) !== JSON.stringify(delta)) { editor.setContents(delta, SOURCE.SILENT); } this.__updateHtmlValue(); if (this._toolbarState === STATE.CLICKED) { this._cleanToolbarState(); this.__emitChangeEvent(); } else if (!this._editor.hasFocus()) { // Value changed from outside this.__lastCommittedChange = this.value; } } /** * Fired when the user commits a value change. * * @event change */ } customElements.define(RichTextEditor.is, RichTextEditor); export { RichTextEditor };