textarea-selection-bounds
Version:
A handy package to get the bounds of the current text selection in a textarea element
298 lines • 13.1 kB
JavaScript
const ZERO_WIDTH_SPACE = '\u200B';
const defaultRelevantStyles = [
'font',
'lineHeight',
'border',
'padding',
'overflowWrap',
];
const debugMarkerId = 'textarea-selection-bounds-debug-marker';
const measureDivId = 'textarea-selection-bounds-div';
const supportedElements = ['textarea', 'text', 'search', 'url', 'tel', 'password'];
export class TextareaSelectionBounds {
/**
* Creates a new instance of TextareaSelectionBounds.
* @param textElement The textarea or input element to get the selection bounds for.
* @param options The options to use.
*/
constructor(textElement, options) {
var _a, _b, _c;
// @internal
this._cache = {
textContent: '',
selection: { from: 0, to: 0 },
result: { top: 0, left: 0, width: 0, height: 0, changed: false, text: '' },
amountOfScrollY: 0,
amountOfScrollX: 0,
textElementTop: 0,
textElementLeft: 0,
textElementWidth: 0,
textElementHeight: 0,
};
// @internal
this._limitCache = [];
this._textElement = textElement;
const inpuType = textElement.tagName === 'INPUT' ? textElement.getAttribute('type') || 'text' : 'textarea';
if (!supportedElements.includes(inpuType === null || inpuType === void 0 ? void 0 : inpuType.toLowerCase())) {
console.error('The textElement element must be of the following types', supportedElements);
throw new Error('Invalid element type');
}
this._options = {
relevantStyles: (_a = options === null || options === void 0 ? void 0 : options.relevantStyles) !== null && _a !== void 0 ? _a : [],
debug: (_b = options === null || options === void 0 ? void 0 : options.debug) !== null && _b !== void 0 ? _b : false,
limits: (_c = options === null || options === void 0 ? void 0 : options.limits) !== null && _c !== void 0 ? _c : [],
};
this._computedTextElementStyle = this.window.getComputedStyle(this._textElement);
}
// @internal
getAllKeysStartingWith(startingWith) {
return Array.from(this._computedTextElementStyle).filter(key => startingWith.some(start => key.startsWith(start)));
}
// @internal
get relevantStyles() {
const defaultRelevantStylesDashCase = defaultRelevantStyles.map(s => s.replace(/[A-Z]/g, '-$&').toLowerCase());
return [
...this.getAllKeysStartingWith(defaultRelevantStylesDashCase),
...this._options.relevantStyles.map(s => s.replace(/[A-Z]/g, '-$&').toLowerCase()),
];
}
get window() {
const win = this._textElement.ownerDocument.defaultView;
if (!win) {
throw new Error('The textarea element must be in a document with a default view.');
}
return win;
}
// @internal
compareCache(newCache) {
const isEqual = this._cache.textContent === newCache.textContent &&
this._cache.selection.from === newCache.selection.from &&
this._cache.selection.to === newCache.selection.to &&
this._cache.amountOfScrollY === newCache.amountOfScrollY &&
this._cache.amountOfScrollX === newCache.amountOfScrollX &&
this._cache.textElementTop === newCache.textElementTop &&
this._cache.textElementLeft === newCache.textElementLeft &&
this._cache.textElementWidth === newCache.textElementWidth &&
this._cache.textElementHeight === newCache.textElementHeight;
if (!isEqual) {
this._cache.textContent = newCache.textContent;
this._cache.selection = newCache.selection;
this._cache.amountOfScrollY = newCache.amountOfScrollY;
this._cache.amountOfScrollX = newCache.amountOfScrollX;
this._cache.textElementTop = newCache.textElementTop;
this._cache.textElementLeft = newCache.textElementLeft;
this._cache.textElementWidth = newCache.textElementWidth;
this._cache.textElementHeight = newCache.textElementHeight;
}
return isEqual;
}
// @internal
getBoundsForSelection(selection) {
const actualFrom = Math.min(selection.from, selection.to);
const actualTo = Math.max(selection.from, selection.to);
const amountOfScrollY = this._textElement.scrollTop;
const amountOfScrollX = this._textElement.scrollLeft;
const div = document.createElement('div');
div.id = measureDivId;
const copyStyle = this.window.getComputedStyle(this._textElement);
for (const prop of this.relevantStyles) {
div.style[prop] = copyStyle[prop];
}
div.style.whiteSpace = 'pre-wrap';
const widthForMeasureDiv = this._textElement.offsetWidth ===
this._textElement.scrollWidth +
this.pxToNumber(copyStyle.borderRightWidth) +
this.pxToNumber(copyStyle.borderLeftWidth)
? this._textElement.offsetWidth
: this._textElement.scrollWidth;
div.style.width = `${widthForMeasureDiv}px`;
div.style.height = 'auto';
div.style.boxSizing = 'border-box';
if (!this._options.debug) {
div.style.position = 'absolute';
div.style.visibility = 'hidden';
}
const textContentUntilSelection = this._textElement.value.substring(0, actualFrom);
const textContentSelection = this._textElement.value.substring(actualFrom, actualTo);
const textContentAfterSelection = this._textElement.value.substring(actualTo);
const textElementRect = this._textElement.getBoundingClientRect();
const textElementTop = textElementRect.top;
const textElementLeft = textElementRect.left;
if (this.compareCache({
textContent: this._textElement.value,
selection: { from: actualFrom, to: actualTo },
amountOfScrollY,
amountOfScrollX,
textElementTop: textElementTop,
textElementLeft: textElementLeft,
textElementWidth: this._textElement.offsetWidth,
textElementHeight: this._textElement.offsetHeight,
})) {
return this._cache.result;
}
const spanUntilSelection = document.createElement('span');
spanUntilSelection.textContent = textContentUntilSelection;
const spanSelection = document.createElement('span');
spanSelection.textContent = textContentSelection + ZERO_WIDTH_SPACE;
if (this._options.debug) {
spanSelection.style.backgroundColor = 'rgba(0, 0, 255, 0.3)';
}
const spanAfterSelection = document.createElement('span');
spanAfterSelection.textContent = textContentAfterSelection;
div.appendChild(spanUntilSelection);
div.appendChild(spanSelection);
div.appendChild(spanAfterSelection);
if (this._options.debug) {
const existingDiv = document.getElementById(measureDivId);
if (existingDiv) {
document.body.removeChild(existingDiv);
}
}
document.body.appendChild(div);
const divRect = div.getBoundingClientRect();
const divTop = divRect.top;
const divLeft = divRect.left;
const spanSelectionRect = spanSelection.getBoundingClientRect();
let top = spanSelectionRect.top - divTop - amountOfScrollY + textElementTop;
let left = spanSelectionRect.left - divLeft - amountOfScrollX + textElementLeft;
let height = spanSelection.offsetHeight;
let width = spanSelection.offsetWidth;
if (this._options.limits.length) {
const limitingElements = this._options.limits
.map((limit, i) => {
var _a;
var _b;
if (limit === 'self') {
return this._textElement;
}
else if (typeof limit === 'function') {
(_a = (_b = this._limitCache)[i]) !== null && _a !== void 0 ? _a : (_b[i] = limit());
return this._limitCache[i];
}
else {
return limit;
}
})
.filter(el => el !== null);
limitingElements.forEach(el => {
const elRect = el.getBoundingClientRect();
const elTop = elRect.top;
const elLeft = elRect.left;
const elHeight = elRect.height;
const elWidth = elRect.width;
if (top < elTop) {
height -= elTop - top;
top = elTop;
}
if (left < elLeft) {
width -= elLeft - left;
left = elLeft;
}
if (top + height > elTop + elHeight) {
height = elTop + elHeight - top;
}
if (left + width > elLeft + elWidth) {
width = elLeft + elWidth - left;
}
if (height < 0) {
height = 0;
}
if (width < 0) {
width = 0;
}
});
}
if (!this._options.debug) {
document.body.removeChild(div);
}
if (this._cache.result.top === top &&
this._cache.result.left === left &&
this._cache.result.height === height &&
this._cache.result.width === width) {
return this._cache.result;
}
this._cache.result = { top, left, height, width, changed: false, text: textContentSelection };
const res = {
top,
left,
height,
width,
changed: true,
text: textContentSelection,
};
// Logs & draws a box around the selection in debug mode
if (this._options.debug) {
console.log(res);
const marker = document.createElement('div');
marker.id = debugMarkerId;
marker.style.position = 'fixed';
marker.style.pointerEvents = 'none';
marker.style.backgroundColor = '#ff00424d';
marker.style.top = `${res.top}px`;
marker.style.left = `${res.left}px`;
marker.style.width = `${res.width}px`;
marker.style.height = `${res.height}px`;
marker.style.zIndex = '999999999';
const existingMarker = document.getElementById(debugMarkerId);
if (existingMarker) {
document.body.removeChild(existingMarker);
}
document.body.appendChild(marker);
}
return res;
}
/**
* Deletes the style cache. Call this is the textElement style has changed (e.g. font size, padding, etc.)
*/
deleteStyleCache() {
this._computedTextElementStyle = this.window.getComputedStyle(this._textElement);
this._limitCache.length = 0;
}
/**
* Returns the current selection bounds.
* @returns The current selection bounds.
* @example
* const bounds = textareaSelectionBounds.getCurrentSelection();
* console.log(bounds);
* // { from: 0, to: 5 }
*/
getCurrentSelection() {
var _a, _b;
return {
from: (_a = this._textElement.selectionStart) !== null && _a !== void 0 ? _a : 0,
to: (_b = this._textElement.selectionEnd) !== null && _b !== void 0 ? _b : 0,
};
}
/**
* Returns the bounds of the selection.
* @param selection The selection to get the bounds for. If not provided, the current selection will be used. If 'full' is provided, it is assumed that all text is selected.
* @returns The bounds of the selection, a changed flag, and the selected text.
* @example
* const bounds = textareaSelectionBounds.getBounds();
* console.log(bounds);
* // { top: 10, left: 20, width: 30, height: 40, changed: true, text: 'Hello' }
*/
getBounds(selection) {
const useSelection = selection !== null && selection !== void 0 ? selection : this.getCurrentSelection();
if (useSelection === 'full') {
return this.getBoundsForSelection({
from: 0,
to: this._textElement.value.length,
});
}
return this.getBoundsForSelection(useSelection);
}
/**
* Returns the bounding client rect of the selection.
* @param selection The selection to get the bounding client rect for. If not provided, the current selection will be used.
* @returns The bounding client rect of the selection.
*/
getBoundingClientRect(selection) {
const bounds = this.getBounds(selection);
return new DOMRect(bounds.left, bounds.top, bounds.width, bounds.height);
}
pxToNumber(px) {
return parseFloat(px.replace('px', ''));
}
}
//# sourceMappingURL=textarea-selection-bounds.js.map