@ckeditor/ckeditor5-ui
Version:
The UI framework and standard UI library of CKEditor 5.
190 lines (189 loc) • 8.23 kB
JavaScript
/**
* @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;
}