caretHelper.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  1. // Based on https://github.com/component/textarea-caret-position/blob/master/index.js
  2. export class CaretHelper {
  3. public static getCaretCoordinates(
  4. element: HTMLInputElement | HTMLTextAreaElement,
  5. position: number,
  6. options?: { debug: boolean }
  7. ) {
  8. if (!isBrowser) {
  9. throw new Error(
  10. "textarea-caret-position#getCaretCoordinates should only be called in a browser"
  11. );
  12. }
  13. const debug = options?.debug ?? false;
  14. if (debug) {
  15. const el = document.querySelector(
  16. "#input-textarea-caret-position-mirror-div"
  17. );
  18. if (el) el.parentNode?.removeChild(el);
  19. }
  20. // The mirror div will replicate the textarea's style
  21. const div = document.createElement("div");
  22. div.id = "input-textarea-caret-position-mirror-div";
  23. document.body.appendChild(div);
  24. const style = div.style;
  25. const computed = window.getComputedStyle
  26. ? window.getComputedStyle(element)
  27. : ((element as any).currentStyle as CSSStyleDeclaration); // currentStyle for IE < 9
  28. const isInput = element.nodeName === "INPUT";
  29. // Default textarea styles
  30. style.whiteSpace = "pre-wrap";
  31. if (!isInput) style.wordWrap = "break-word"; // only for textarea-s
  32. // Position off-screen
  33. style.position = "absolute"; // required to return coordinates properly
  34. if (!debug) style.visibility = "hidden"; // not 'display: none' because we want rendering
  35. // Transfer the element's properties to the div
  36. properties.forEach((prop: string) => {
  37. if (isInput && prop === "lineHeight") {
  38. // Special case for <input>s because text is rendered centered and line height may be != height
  39. if (computed.boxSizing === "border-box") {
  40. const height = parseInt(computed.height);
  41. const outerHeight =
  42. parseInt(computed.paddingTop) +
  43. parseInt(computed.paddingBottom) +
  44. parseInt(computed.borderTopWidth) +
  45. parseInt(computed.borderBottomWidth);
  46. const targetHeight = outerHeight + parseInt(computed.lineHeight);
  47. if (height > targetHeight) {
  48. style.lineHeight = `${height - outerHeight}px`;
  49. } else if (height === targetHeight) {
  50. style.lineHeight = computed.lineHeight;
  51. } else {
  52. style.lineHeight = "0";
  53. }
  54. } else {
  55. style.lineHeight = computed.height;
  56. }
  57. } else {
  58. (style as any)[prop] = (computed as any)[prop];
  59. }
  60. });
  61. if (isFirefox) {
  62. // Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275
  63. if (element.scrollHeight > parseInt(computed.height)) {
  64. style.overflowY = "scroll";
  65. }
  66. } else {
  67. style.overflow = "hidden"; // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll'
  68. }
  69. div.textContent = element.value.substring(0, position);
  70. // The second special handling for input type="text" vs textarea:
  71. // spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037
  72. if (isInput) div.textContent = div.textContent.replace(/\s/g, "\u00a0");
  73. const span = document.createElement("span");
  74. // Wrapping must be replicated *exactly*, including when a long word gets
  75. // onto the next line, with whitespace at the end of the line before (#7).
  76. // The *only* reliable way to do that is to copy the *entire* rest of the
  77. // textarea's content into the <span> created at the caret position.
  78. // For inputs, just '.' would be enough, but no need to bother.
  79. span.textContent = element.value.substring(position) || "."; // || because a completely empty faux span doesn't render at all
  80. div.appendChild(span);
  81. const coordinates = {
  82. top: span.offsetTop + parseInt(computed.borderTopWidth),
  83. left: span.offsetLeft + parseInt(computed.borderLeftWidth),
  84. height: parseInt(computed.lineHeight)
  85. };
  86. if (debug) {
  87. span.style.backgroundColor = "#aaa";
  88. } else {
  89. document.body.removeChild(div);
  90. }
  91. return coordinates;
  92. }
  93. }
  94. const properties = [
  95. "direction", // RTL support
  96. "boxSizing",
  97. "width", // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does
  98. "height",
  99. "overflowX",
  100. "overflowY", // copy the scrollbar for IE
  101. "borderTopWidth",
  102. "borderRightWidth",
  103. "borderBottomWidth",
  104. "borderLeftWidth",
  105. "borderStyle",
  106. "paddingTop",
  107. "paddingRight",
  108. "paddingBottom",
  109. "paddingLeft",
  110. // https://developer.mozilla.org/en-US/docs/Web/CSS/font
  111. "fontStyle",
  112. "fontVariant",
  113. "fontWeight",
  114. "fontStretch",
  115. "fontSize",
  116. "fontSizeAdjust",
  117. "lineHeight",
  118. "fontFamily",
  119. "textAlign",
  120. "textTransform",
  121. "textIndent",
  122. "textDecoration", // might not make a difference, but better be safe
  123. "letterSpacing",
  124. "wordSpacing",
  125. "tabSize",
  126. "MozTabSize"
  127. ];
  128. const isBrowser = typeof window !== "undefined";
  129. const isFirefox = isBrowser && (window as any).mozInnerScreenX != null;