UNPKG

@ckeditor/ckeditor5-ui

Version:

The UI framework and standard UI library of CKEditor 5.

190 lines (189 loc) 8.23 kB
/** * @license Copyright (c) 2003-2025, CKSource Holding sp. z o.o. All rights reserved. * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-licensing-options */ /** * @module ui/textarea/textareaview */ import { Rect, toUnit, getBorderWidths, global, CKEditorError, isVisible, ResizeObserver } from '@ckeditor/ckeditor5-utils'; import InputBase from '../input/inputbase.js'; import '../../theme/components/input/input.css'; import '../../theme/components/textarea/textarea.css'; /** * The textarea view class. * * ```ts * const textareaView = new TextareaView(); * * textareaView.minRows = 2; * textareaView.maxRows = 10; * * textareaView.render(); * * document.body.append( textareaView.element ); * ``` */ export default class TextareaView extends InputBase { /** * An instance of the resize observer used to detect when the view is visible or not and update * its height if any changes that affect it were made while it was invisible. * * **Note:** Created in {@link #render}. */ _resizeObserver; /** * A flag that indicates whether the {@link #_updateAutoGrowHeight} method should be called when the view becomes * visible again. See {@link #_resizeObserver}. */ _isUpdateAutoGrowHeightPending = false; /** * @inheritDoc */ constructor(locale) { super(locale); const toPx = toUnit('px'); this.set('minRows', 2); this.set('maxRows', 5); this.set('_height', null); this.set('resize', 'none'); this._resizeObserver = null; this.on('change:minRows', this._validateMinMaxRows.bind(this)); this.on('change:maxRows', this._validateMinMaxRows.bind(this)); const bind = this.bindTemplate; this.template.tag = 'textarea'; this.extendTemplate({ attributes: { class: ['ck-textarea'], style: { height: bind.to('_height', height => height ? toPx(height) : null), resize: bind.to('resize') }, rows: bind.to('minRows') } }); } /** * @inheritDoc */ render() { super.render(); let wasVisible = false; this.on('input', () => { this._updateAutoGrowHeight(true); this.fire('update'); }); this.on('change:value', () => { // The content needs to be updated by the browser after the value is changed. It takes a few ms. global.window.requestAnimationFrame(() => { if (!isVisible(this.element)) { this._isUpdateAutoGrowHeightPending = true; return; } this._updateAutoGrowHeight(); this.fire('update'); }); }); // It may occur that the Textarea size needs to be updated (e.g. because it's content was changed) // when it is not visible or detached from DOM. // In such case, we need to detect the moment when it becomes visible again and update its height then. // We're using ResizeObserver for that as it is the most reliable way to detect when the element becomes visible. // IntersectionObserver didn't work well with the absolute positioned containers. this._resizeObserver = new ResizeObserver(this.element, evt => { const isVisible = !!evt.contentRect.width && !!evt.contentRect.height; if (!wasVisible && isVisible && this._isUpdateAutoGrowHeightPending) { // We're wrapping the auto-grow logic in RAF because otherwise there is an error thrown // by the browser about recursive calls to the ResizeObserver. It used to happen in unit // tests only, though. Since there is no risk of infinite loop here, it can stay here. global.window.requestAnimationFrame(() => { this._updateAutoGrowHeight(); this.fire('update'); }); } wasVisible = isVisible; }); } /** * @inheritDoc */ destroy() { if (this._resizeObserver) { this._resizeObserver.destroy(); } } /** * @inheritDoc */ reset() { super.reset(); this._updateAutoGrowHeight(); this.fire('update'); } /** * Updates the {@link #_height} of the view depending on {@link #minRows}, {@link #maxRows}, and the current content size. * * **Note**: This method overrides manual resize done by the user using a handle. It's a known bug. */ _updateAutoGrowHeight(shouldScroll) { const viewElement = this.element; if (!viewElement.offsetParent) { this._isUpdateAutoGrowHeightPending = true; return; } this._isUpdateAutoGrowHeightPending = false; const singleLineContentClone = getTextareaElementClone(viewElement, '1'); const fullTextValueClone = getTextareaElementClone(viewElement, viewElement.value); const singleLineContentStyles = singleLineContentClone.ownerDocument.defaultView.getComputedStyle(singleLineContentClone); const verticalPaddings = parseFloat(singleLineContentStyles.paddingTop) + parseFloat(singleLineContentStyles.paddingBottom); const borders = getBorderWidths(singleLineContentClone); const lineHeight = parseFloat(singleLineContentStyles.lineHeight); const verticalBorder = borders.top + borders.bottom; const singleLineAreaDefaultHeight = new Rect(singleLineContentClone).height; const numberOfLines = Math.round((fullTextValueClone.scrollHeight - verticalPaddings) / lineHeight); const maxHeight = this.maxRows * lineHeight + verticalPaddings + verticalBorder; // There's a --ck-ui-component-min-height CSS custom property that enforces min height of the component. // This min-height is relevant only when there's one line of text. Other than that, we can rely on line-height. const minHeight = numberOfLines === 1 ? singleLineAreaDefaultHeight : this.minRows * lineHeight + verticalPaddings + verticalBorder; // The size of textarea is controlled by height style instead of rows attribute because event though it is // a more complex solution, it is immune to the layout textarea has been rendered in (gird, flex). this._height = Math.min(Math.max(Math.max(numberOfLines, this.minRows) * lineHeight + verticalPaddings + verticalBorder, minHeight), maxHeight); if (shouldScroll) { viewElement.scrollTop = viewElement.scrollHeight; } singleLineContentClone.remove(); fullTextValueClone.remove(); } /** * Validates the {@link #minRows} and {@link #maxRows} properties and warns in the console if the configuration is incorrect. */ _validateMinMaxRows() { if (this.minRows > this.maxRows) { /** * The minimum number of rows is greater than the maximum number of rows. * * @error ui-textarea-view-min-rows-greater-than-max-rows * @param {module:ui/textarea/textareaview~TextareaView} textareaView The misconfigured textarea view instance. * @param {number} minRows The value of `minRows` property. * @param {number} maxRows The value of `maxRows` property. */ throw new CKEditorError('ui-textarea-view-min-rows-greater-than-max-rows', { textareaView: this, minRows: this.minRows, maxRows: this.maxRows }); } } } function getTextareaElementClone(element, value) { const clone = element.cloneNode(); clone.style.position = 'absolute'; clone.style.top = '-99999px'; clone.style.left = '-99999px'; clone.style.height = 'auto'; clone.style.overflow = 'hidden'; clone.style.width = element.ownerDocument.defaultView.getComputedStyle(element).width; clone.tabIndex = -1; clone.rows = 1; clone.value = value; element.parentNode.insertBefore(clone, element); return clone; }