@vaadin/rich-text-editor
Version:
vaadin-rich-text-editor
1,012 lines (878 loc) • 30.9 kB
JavaScript
/**
* @license
* Copyright (c) 2000 - 2026 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 '../vendor/vaadin-quill.js';
import { isKeyboardActive } from '@vaadin/a11y-base/src/focus-utils.js';
import { timeOut } from '@vaadin/component-base/src/async.js';
import { Debouncer } from '@vaadin/component-base/src/debounce.js';
import { I18nMixin } from '@vaadin/component-base/src/i18n-mixin.js';
const Quill = window.Quill;
// There are some issues e.g. `spellcheck="false"` not preserved
// See https://github.com/slab/quill/issues/4289
// Fix to add `spellcheck="false"` on the `<pre>` tag removed by Quill
const QuillCodeBlockContainer = Quill.import('formats/code-block-container');
class CodeBlockContainer extends QuillCodeBlockContainer {
html(index, length) {
const markup = super.html(index, length);
const tempDiv = document.createElement('div');
tempDiv.innerHTML = markup;
const preTag = tempDiv.querySelector('pre');
if (preTag) {
preTag.setAttribute('spellcheck', 'false');
return preTag.outerHTML;
}
return markup; // fallback
}
}
Quill.register('formats/code-block-container', CodeBlockContainer, true);
const NAVIGATION_KEYS = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'];
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 DEFAULT_I18N = {
undo: 'undo',
redo: 'redo',
bold: 'bold',
italic: 'italic',
underline: 'underline',
strike: 'strike',
color: 'color',
background: 'background',
h1: 'h1',
h2: 'h2',
h3: 'h3',
subscript: 'subscript',
superscript: 'superscript',
listOrdered: 'list ordered',
listBullet: 'list bullet',
outdent: 'outdent',
indent: 'indent',
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',
};
/**
* @polymerMixin
*/
export const RichTextEditorMixin = (superClass) =>
class RichTextEditorMixinClass extends I18nMixin(DEFAULT_I18N, superClass) {
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.
*/
value: {
type: String,
notify: true,
value: '',
sync: true,
},
/**
* 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.
*/
disabled: {
type: Boolean,
value: false,
reflectToAttribute: true,
},
/**
* When true, the user can not modify the editor content, but can copy it.
*/
readonly: {
type: Boolean,
value: false,
reflectToAttribute: true,
},
/**
* The list of colors used by the background and text color
* selection controls. Should contain an array of HEX strings.
*
* When user selects `#000000` (black) as a text color,
* or `#ffffff` (white) as a background color, it resets
* the corresponding format for the selected text.
*/
colorOptions: {
type: Array,
value: () => {
/* prettier-ignore */
return [
'#000000', '#e60000', '#ff9900', '#ffff00', '#008a00', '#0066cc', '#9933ff',
'#ffffff', '#facccc', '#ffebcc', '#ffffcc', '#cce8cc', '#cce0f5', '#ebd6ff',
'#bbbbbb', '#f06666', '#ffc266', '#ffff66', '#66b966', '#66a3e0', '#c285ff',
'#888888', '#a10000', '#b26b00', '#b2b200', '#006100', '#0047b2', '#6b24b2',
'#444444', '#5c0000', '#663d00', '#666600', '#003700', '#002966', '#3d1466',
];
},
},
/** @private */
_editor: {
type: Object,
sync: true,
},
/**
* Stores old value
* @private
*/
__oldValue: String,
/** @private */
__lastCommittedChange: {
type: String,
value: '',
},
/** @private */
_linkEditing: {
type: Boolean,
value: false,
},
/** @private */
_linkRange: {
type: Object,
value: null,
},
/** @private */
_linkIndex: {
type: Number,
value: null,
},
/** @private */
_linkUrl: {
type: String,
value: '',
},
/** @private */
_colorEditing: {
type: Boolean,
value: false,
},
/** @private */
_colorValue: {
type: String,
value: '',
},
/** @private */
_backgroundEditing: {
type: Boolean,
value: false,
},
/** @private */
_backgroundValue: {
type: String,
value: '',
},
};
}
static get observers() {
return ['_valueChanged(value, _editor)', '_disabledChanged(disabled, readonly, _editor)'];
}
/**
* The object used to localize this component. To change the default
* localization, replace this with an object that provides all properties, or
* just the individual properties you want to change.
*
* The properties are used e.g. as the tooltips for the editor toolbar
* buttons.
*
* @type {!RichTextEditorI18n}
*/
get i18n() {
return super.i18n;
}
set i18n(value) {
super.i18n = value;
}
/** @private */
get _toolbarButtons() {
return Array.from(this.shadowRoot.querySelectorAll('[part="toolbar"] button')).filter((btn) => {
return btn.clientHeight > 0;
});
}
/**
* @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.disconnect();
}
/** @private */
__setDirection(dir) {
if (!this._editor) {
return;
}
// 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();
this._editor.emitter.connect();
}
/** @protected */
ready() {
super.ready();
this._toolbarConfig = this._prepareToolbar();
this._toolbar = this._toolbarConfig.container;
this._addToolbarListeners();
const editor = this.shadowRoot.querySelector('[part="content"]');
this._editor = new Quill(editor, {
modules: {
toolbar: this._toolbarConfig,
},
});
this.__patchToolbar();
this.__patchKeyboard();
this.__setDirection(this.__dir);
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);
});
});
this._editor.on('editor-change', () => {
const selection = this._editor.getSelection();
if (selection) {
const format = this._editor.getFormat(selection.index, selection.length);
this._toolbar.style.setProperty('--_color-value', format.color || null);
this._toolbar.style.setProperty('--_background-value', format.background || null);
}
});
editorContent.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (!this.__tabBindings) {
this.__tabBindings = this._editor.keyboard.bindings.Tab;
this._editor.keyboard.bindings.Tab = null;
}
} else if (this.__tabBindings) {
this._editor.keyboard.bindings.Tab = this.__tabBindings;
this.__tabBindings = null;
}
});
// Workaround for Quill 2.0 reporting stale selection on navigation keys.
// Quill's selectionchange handler reads the selection before the browser
// commits the new cursor position, causing toolbar to show previous format.
// Deferring via rAF ensures we read the correct, committed selection.
// See https://github.com/slab/quill/issues/4305
// See https://github.com/slab/quill/issues/4168
editorContent.addEventListener('keydown', (e) => {
if (NAVIGATION_KEYS.includes(e.key)) {
requestAnimationFrame(() => {
const selection = this._editor.getSelection();
if (selection) {
this._editor.getModule('toolbar').update(selection);
}
});
}
});
editorContent.addEventListener('blur', () => {
if (this.__tabBindings) {
this._editor.keyboard.bindings.Tab = this.__tabBindings;
this.__tabBindings = null;
}
});
editorContent.addEventListener('focusout', () => {
if (this._toolbarState === STATE.FOCUSED) {
this._cleanToolbarState();
} else {
this.__emitChangeEvent();
}
});
editorContent.addEventListener('focus', () => {
// When editing link, editor gets focus while dialog is still open.
// Keep toolbar state as clicked in this case to fire change event.
if (this._toolbarState === STATE.CLICKED && !this._linkEditing) {
this._cleanToolbarState();
}
});
this._editor.on('selection-change', this.__announceFormatting.bind(this));
// Flush pending htmlValue only once the editor is fully initialized
this.__flushPendingHtmlValue();
this.querySelector('[slot="color-popup"]').target = this.shadowRoot.querySelector('#btn-color');
this.querySelector('[slot="background-popup"]').target = this.shadowRoot.querySelector('#btn-background');
// Set up tooltip to show when hovering or focusing toolbar buttons
this._tooltip = document.createElement('vaadin-tooltip');
this._tooltip.slot = 'tooltip';
// Set ariaTarget to null, as toolbar buttons already have aria-label,
// and also cannot be linked with the tooltip being in the light DOM
this._tooltip.ariaTarget = null;
this.append(this._tooltip);
const buttons = this.shadowRoot.querySelectorAll('[part~="toolbar-button"]');
buttons.forEach((button) => {
button.addEventListener('mouseenter', this.__showTooltip.bind(this));
button.addEventListener('focusin', this.__showTooltip.bind(this));
});
}
/** @private */
__showTooltip({ type, target }) {
// Only show tooltip when focused with keyboard
if (type === 'focusin' && !isKeyboardActive()) {
return;
}
this._tooltip.target = target;
this._tooltip.text = target.ariaLabel;
this._tooltip._stateController.open({
focus: type === 'focusin',
hover: type === 'mouseenter',
});
}
/** @private */
_prepareToolbar() {
const clean = Quill.imports['modules/toolbar'].DEFAULTS.handlers.clean;
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.key === 'Tab' && !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 */
__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];
const isActive = input.classList.contains('ql-active');
input.part.toggle('toolbar-button-pressed', isActive);
});
};
}
/** @private */
__patchKeyboard() {
const focusToolbar = () => {
this._markToolbarFocused();
this._toolbar.querySelector('button:not([tabindex])').focus();
};
const keyboard = this._editor.keyboard;
// Shift + Tab focuses a toolbar button unless we are in list / code block
keyboard.addBinding({ key: 'Tab', shiftKey: true, handler: focusToolbar });
// Alt-f10 focuses a toolbar button
keyboard.addBinding({ key: 'F10', 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.focus();
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._onLinkEditConfirm();
this._closeLinkDialog();
}
}
/** @private */
__onColorClick() {
this._tooltip.opened = false;
this._colorEditing = true;
}
/** @private */
__onColorSelected(event) {
const color = event.detail.color;
this._colorValue = color === '#000000' ? null : color;
this._markToolbarClicked();
this._editor.focus();
this._editor.format('color', this._colorValue, SOURCE.USER);
this._toolbar.style.setProperty('--_color-value', this._colorValue);
this._colorEditing = false;
}
/** @private */
__onBackgroundClick() {
this._tooltip.opened = false;
this._backgroundEditing = true;
}
/** @private */
__onBackgroundSelected(event) {
const color = event.detail.color;
this._backgroundValue = color === '#ffffff' ? null : color;
this._markToolbarClicked();
this._editor.focus();
this._editor.format('background', this._backgroundValue, SOURCE.USER);
this._toolbar.style.setProperty('--_background-value', this._backgroundValue);
this._backgroundEditing = false;
}
/** @private */
__updateHtmlValue() {
// We have to use this instead of `innerHTML` to get correct tags like `<pre>` etc.
let content = this._editor.getSemanticHTML();
// Remove Quill classes, e.g. ql-syntax, except for align and indent
content = content.replace(/class="([^"]*)"/gu, (_match, group1) => {
const classes = group1.split(' ').filter((className) => {
return !className.startsWith('ql-') || className.startsWith('ql-align') || className.startsWith('ql-indent');
});
return `class="${classes.join(' ')}"`;
});
// Process align and indent classes
content = this.__processQuillClasses(content);
this._setHtmlValue(content);
}
/** @private */
__processQuillClasses(content) {
const tempDiv = document.createElement('div');
tempDiv.innerHTML = content;
// Process only elements with align or indent classes
const elementsToProcess = tempDiv.querySelectorAll('[class*="ql-align"], [class*="ql-indent"]');
elementsToProcess.forEach((element) => {
this.__processAlignClasses(element);
this.__processIndentClasses(element);
element.removeAttribute('class');
});
return tempDiv.innerHTML;
}
/** @private */
__processAlignClasses(element) {
let styleText = element.getAttribute('style') || '';
const alignments = [this.__dir === 'rtl' ? 'left' : 'right', 'center', 'justify'];
alignments.forEach((align) => {
if (element.classList.contains(`ql-align-${align}`)) {
const newStyle = `text-align: ${align}`;
styleText = styleText ? `${styleText}; ${newStyle}` : newStyle;
element.setAttribute('style', styleText);
element.classList.remove(`ql-align-${align}`);
}
});
}
/** @private */
__processIndentClasses(element) {
const indentClass = Array.from(element.classList).find((className) => className.startsWith('ql-indent-'));
if (indentClass) {
const level = parseInt(indentClass.replace('ql-indent-', '').trim(), 10);
const tabs = '\t'.repeat(level);
// Add tabs to content
const firstChild = element.firstChild;
if (firstChild && firstChild.nodeType === Node.TEXT_NODE) {
firstChild.textContent = tabs + firstChild.textContent;
} else if (element.childNodes.length > 0) {
const tabNode = document.createTextNode(tabs);
element.insertBefore(tabNode, element.firstChild);
} else {
element.textContent = tabs;
}
element.classList.remove(indentClass);
}
}
/**
* 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) {
if (!this._editor) {
this.__savePendingHtmlValue(htmlValue);
return;
}
// In Firefox, the styles are not properly computed when the element is placed
// in a Lit component, as the element is first attached to the DOM and then
// the shadowRoot is initialized. This causes the `hmlValue` to not be correctly
// parsed into the delta format used by Quill. To work around this, we check
// if the display property is set and if not, we wait for the element to intersect
// with the viewport before trying to set the value again.
if (!getComputedStyle(this).display) {
this.__savePendingHtmlValue(htmlValue);
const observer = new IntersectionObserver(() => {
if (getComputedStyle(this).display) {
this.__flushPendingHtmlValue();
observer.disconnect();
}
});
observer.observe(this);
return;
}
const whitespaceCharacters = {
'\t': '__VAADIN_RICH_TEXT_EDITOR_TAB',
' ': '__VAADIN_RICH_TEXT_EDITOR_DOUBLE_SPACE',
};
// Replace whitespace characters with placeholders before the Delta conversion to prevent Quill from trimming them
Object.entries(whitespaceCharacters).forEach(([character, replacement]) => {
htmlValue = htmlValue.replaceAll(/>[^<]*</gu, (match) => match.replaceAll(character, replacement)); // NOSONAR
});
const deltaFromHtml = this._editor.clipboard.convert({ html: htmlValue });
// Restore whitespace characters after the conversion
Object.entries(whitespaceCharacters).forEach(([character, replacement]) => {
deltaFromHtml.ops.forEach((op) => {
if (typeof op.insert === 'string') {
op.insert = op.insert.replaceAll(replacement, character);
}
});
});
this._editor.setContents(deltaFromHtml, SOURCE.API);
}
/** @private */
__savePendingHtmlValue(htmlValue) {
// The editor isn't ready yet, store the value for later
this.__pendingHtmlValue = htmlValue;
// Clear a possible value to prevent it from clearing the pending htmlValue once the editor property is set
this.value = '';
}
/** @private */
__flushPendingHtmlValue() {
if (this.__pendingHtmlValue) {
this.dangerouslySetHtmlValue(this.__pendingHtmlValue);
}
}
/** @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) => button.getAttribute('aria-label'))
.join(', ');
announcer.textContent = formatting;
},
);
}
/** @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 (value && this.__pendingHtmlValue) {
// A non-empty value is set explicitly. Clear pending htmlValue to prevent it from overriding the value.
this.__pendingHtmlValue = undefined;
}
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;
}
}
};