2024-01-29 09:26:07 +08:00
|
|
|
/**
|
|
|
|
* The original package is https://www.npmjs.com/package/textarea-caret-ts
|
|
|
|
* The original file is https://github.com/TheRealSyler/textarea-caret-position/blob/master/index.ts
|
|
|
|
*
|
|
|
|
* Here I modified it to make it works when input is scrolled.
|
|
|
|
*/
|
|
|
|
import { isBrowser } from "../../_utils/index.mjs";
|
|
|
|
/**
|
|
|
|
* Returns the Absolute (relative to the inner window size) position of the caret in the given element.
|
|
|
|
* @param element Input (has to be type='text') or Text Area.
|
|
|
|
*/
|
|
|
|
export function getAbsolutePosition(element) {
|
|
|
|
const caretRelPost = getRelativePosition(element);
|
|
|
|
return {
|
|
|
|
left: window.scrollX + element.getBoundingClientRect().left + caretRelPost.left,
|
|
|
|
top: window.scrollY + element.getBoundingClientRect().top + caretRelPost.top,
|
|
|
|
absolute: true,
|
|
|
|
height: caretRelPost.height
|
|
|
|
};
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* Returns the relative position of the caret in the given element.
|
|
|
|
* @param element Input (has to be type='text') or Text Area.
|
|
|
|
*/
|
|
|
|
export function getRelativePosition(element, options = {
|
|
|
|
debug: false,
|
|
|
|
useSelectionEnd: false,
|
|
|
|
checkWidthOverflow: true
|
|
|
|
}) {
|
|
|
|
const selectionStart = element.selectionStart !== null ? element.selectionStart : 0;
|
|
|
|
const selectionEnd = element.selectionEnd !== null ? element.selectionEnd : 0;
|
|
|
|
const position = options.useSelectionEnd ? selectionEnd : selectionStart;
|
|
|
|
// We'll copy the properties below into the mirror div.
|
|
|
|
// Note that some browsers, such as Firefox, do not concatenate properties
|
|
|
|
// into their shorthand (e.g. padding-top, padding-bottom etc. -> padding),
|
|
|
|
// so we have to list every single property explicitly.
|
|
|
|
const properties = ['direction',
|
|
|
|
// RTL support
|
|
|
|
'boxSizing', 'width',
|
|
|
|
// on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does
|
|
|
|
'height', 'overflowX', 'overflowY',
|
|
|
|
// copy the scrollbar for IE
|
|
|
|
'borderTopWidth', 'borderRightWidth', 'borderBottomWidth', 'borderLeftWidth', 'borderStyle', 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
|
|
|
|
// https://developer.mozilla.org/en-US/docs/Web/CSS/font
|
|
|
|
'fontStyle', 'fontVariant', 'fontWeight', 'fontStretch', 'fontSize', 'fontSizeAdjust', 'lineHeight', 'fontFamily', 'textAlign', 'textTransform', 'textIndent', 'textDecoration',
|
|
|
|
// might not make a difference, but better be safe
|
|
|
|
'letterSpacing', 'wordSpacing', 'tabSize', 'MozTabSize'];
|
|
|
|
// Firefox 1.0+
|
|
|
|
const isFirefox = navigator.userAgent.toLowerCase().includes('firefox');
|
|
|
|
if (!isBrowser) {
|
|
|
|
throw new Error('textarea-caret-position#getCaretPosition should only be called in a browser');
|
|
|
|
}
|
|
|
|
const debug = options === null || options === void 0 ? void 0 : options.debug;
|
|
|
|
if (debug) {
|
|
|
|
const el = document.querySelector('#input-textarea-caret-position-mirror-div');
|
|
|
|
if (el === null || el === void 0 ? void 0 : el.parentNode) el.parentNode.removeChild(el);
|
|
|
|
}
|
|
|
|
// The mirror div will replicate the textareas style
|
|
|
|
const div = document.createElement('div');
|
|
|
|
div.id = 'input-textarea-caret-position-mirror-div';
|
|
|
|
document.body.appendChild(div);
|
|
|
|
const style = div.style;
|
|
|
|
const computed = window.getComputedStyle ? window.getComputedStyle(element) : element.currentStyle; // currentStyle for IE < 9
|
|
|
|
const isInput = element.nodeName === 'INPUT';
|
|
|
|
// Default textarea styles
|
|
|
|
style.whiteSpace = isInput ? 'nowrap' : 'pre-wrap';
|
|
|
|
if (!isInput) style.wordWrap = 'break-word'; // only for textarea-s
|
|
|
|
// Position off-screen
|
|
|
|
style.position = 'absolute'; // required to return coordinates properly
|
|
|
|
if (!debug) style.visibility = 'hidden'; // not 'display: none' because we want rendering
|
|
|
|
// Transfer the element's properties to the div
|
|
|
|
properties.forEach(prop => {
|
|
|
|
if (isInput && prop === 'lineHeight') {
|
|
|
|
// Special case for <input>s because text is rendered centered and line height may be != height
|
|
|
|
if (computed.boxSizing === 'border-box') {
|
2024-08-02 18:19:39 +08:00
|
|
|
const height = Number.parseInt(computed.height);
|
|
|
|
const outerHeight = Number.parseInt(computed.paddingTop) + Number.parseInt(computed.paddingBottom) + Number.parseInt(computed.borderTopWidth) + Number.parseInt(computed.borderBottomWidth);
|
|
|
|
const targetHeight = outerHeight + Number.parseInt(computed.lineHeight);
|
2024-01-29 09:26:07 +08:00
|
|
|
if (height > targetHeight) {
|
|
|
|
style.lineHeight = `${height - outerHeight}px`;
|
|
|
|
} else if (height === targetHeight) {
|
|
|
|
style.lineHeight = computed.lineHeight;
|
|
|
|
} else {
|
|
|
|
style.lineHeight = '0';
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
style.lineHeight = computed.height;
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
style[prop] = computed[prop];
|
|
|
|
}
|
|
|
|
});
|
|
|
|
if (isFirefox) {
|
|
|
|
// Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275
|
2024-08-02 18:19:39 +08:00
|
|
|
if (element.scrollHeight > Number.parseInt(computed.height)) {
|
2024-01-29 09:26:07 +08:00
|
|
|
style.overflowY = 'scroll';
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
style.overflow = 'hidden'; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll'
|
|
|
|
}
|
|
|
|
div.textContent = element.value.substring(0, position);
|
|
|
|
// The second special handling for input type="text" vs textarea:
|
|
|
|
// spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037
|
|
|
|
if (isInput && div.textContent) {
|
2024-08-02 18:19:39 +08:00
|
|
|
div.textContent = div.textContent.replace(/\s/g, '\u00A0');
|
2024-01-29 09:26:07 +08:00
|
|
|
}
|
|
|
|
const span = document.createElement('span');
|
|
|
|
// Wrapping must be replicated *exactly*, including when a long word gets
|
|
|
|
// onto the next line, with whitespace at the end of the line before (#7).
|
|
|
|
// The *only* reliable way to do that is to copy the *entire* rest of the
|
|
|
|
// textareas content into the <span> created at the caret position.
|
|
|
|
// For inputs, just '.' would be enough, but no need to bother.
|
|
|
|
span.textContent = element.value.substring(position) || '.'; // || because a completely empty faux span doesn't render at all
|
|
|
|
span.style.position = 'relative';
|
|
|
|
span.style.left = `${-element.scrollLeft}px`;
|
|
|
|
span.style.top = `${-element.scrollTop}px`;
|
|
|
|
div.appendChild(span);
|
|
|
|
const relativePosition = {
|
2024-08-02 18:19:39 +08:00
|
|
|
top: span.offsetTop + Number.parseInt(computed.borderTopWidth),
|
|
|
|
left: span.offsetLeft + Number.parseInt(computed.borderLeftWidth),
|
2024-01-29 09:26:07 +08:00
|
|
|
absolute: false,
|
|
|
|
// We don't use line-height since it may be too large for position. Eg. 34px
|
|
|
|
// for input
|
2024-08-02 18:19:39 +08:00
|
|
|
height: Number.parseInt(computed.fontSize) * 1.5
|
2024-01-29 09:26:07 +08:00
|
|
|
};
|
|
|
|
if (debug) {
|
|
|
|
span.style.backgroundColor = '#aaa';
|
|
|
|
} else {
|
|
|
|
document.body.removeChild(div);
|
|
|
|
}
|
|
|
|
if (relativePosition.left >= element.clientWidth && options.checkWidthOverflow) {
|
|
|
|
relativePosition.left = element.clientWidth;
|
|
|
|
}
|
|
|
|
return relativePosition;
|
|
|
|
}
|
|
|
|
/**
|
|
|
|
* sets the top and left css style of the element based on the absolute position of the caretElements caret,
|
|
|
|
* @param offset offsets the position.
|
|
|
|
* @param detectBoundary offsets the position if the position would be outside the window.
|
|
|
|
* @param returnOnly if true the element position wont be set.
|
|
|
|
*/
|
|
|
|
export function setElementPositionBasedOnCaret(element, caretElement, offset = {
|
|
|
|
top: 0,
|
|
|
|
left: 0
|
|
|
|
}, margin = 2, detectBoundary = true, returnOnly = false) {
|
|
|
|
const pos = getAbsolutePosition(caretElement);
|
|
|
|
if (detectBoundary) {
|
|
|
|
pos.left = pos.left + (element.clientWidth + margin) + offset.left > window.scrollX + window.innerWidth ? pos.left = window.scrollX + window.innerWidth - (element.clientWidth + margin) : pos.left += offset.left;
|
|
|
|
pos.top = pos.top + (element.clientWidth + margin) + offset.top > window.scrollY + window.innerHeight ? pos.top -= element.clientWidth + margin : pos.top += offset.top;
|
|
|
|
} else {
|
|
|
|
pos.top += offset.top;
|
|
|
|
pos.left += offset.left;
|
|
|
|
}
|
|
|
|
if (!returnOnly) {
|
|
|
|
element.style.top = `${pos.top}px`;
|
|
|
|
element.style.left = `${pos.left}px`;
|
|
|
|
}
|
|
|
|
return pos;
|
|
|
|
}
|