|
1 |
| -import getCaretCoordinates from '@koddsson/textarea-caret' |
2 |
| - |
3 |
| -export type Coordinates = { |
| 1 | +export type CharacterCoordinates = { |
| 2 | + /** Number of pixels from the origin down to the top edge of the character. */ |
4 | 3 | top: number
|
| 4 | + /** Number of pixels from the origin right to the left edge of the character. */ |
5 | 5 | left: number
|
| 6 | + /** Height of the character. */ |
| 7 | + height: number |
6 | 8 | }
|
7 | 9 |
|
| 10 | +// Note that some browsers, such as Firefox, do not concatenate properties |
| 11 | +// into their shorthand (e.g. padding-top, padding-bottom etc. -> padding), |
| 12 | +// so we have to list every single property explicitly. |
| 13 | +const propertiesToCopy = [ |
| 14 | + 'direction', // RTL support |
| 15 | + 'boxSizing', |
| 16 | + 'width', // on Chrome and IE, exclude the scrollbar, so the mirror div wraps exactly as the textarea does |
| 17 | + 'height', |
| 18 | + 'overflowX', |
| 19 | + 'overflowY', // copy the scrollbar for IE |
| 20 | + |
| 21 | + 'borderTopWidth', |
| 22 | + 'borderRightWidth', |
| 23 | + 'borderBottomWidth', |
| 24 | + 'borderLeftWidth', |
| 25 | + 'borderStyle', |
| 26 | + |
| 27 | + 'paddingTop', |
| 28 | + 'paddingRight', |
| 29 | + 'paddingBottom', |
| 30 | + 'paddingLeft', |
| 31 | + |
| 32 | + // https://developer.mozilla.org/en-US/docs/Web/CSS/font |
| 33 | + 'fontStyle', |
| 34 | + 'fontVariant', |
| 35 | + 'fontWeight', |
| 36 | + 'fontStretch', |
| 37 | + 'fontSize', |
| 38 | + 'fontSizeAdjust', |
| 39 | + 'lineHeight', |
| 40 | + 'fontFamily', |
| 41 | + |
| 42 | + 'textAlign', |
| 43 | + 'textTransform', |
| 44 | + 'textIndent', |
| 45 | + 'textDecoration', // might not make a difference, but better be safe |
| 46 | + |
| 47 | + 'letterSpacing', |
| 48 | + 'wordSpacing', |
| 49 | + |
| 50 | + 'tabSize', |
| 51 | + 'MozTabSize' as 'tabSize' // prefixed version for Firefox <= 52 |
| 52 | +] as const |
| 53 | + |
8 | 54 | /**
|
9 | 55 | * Obtain the coordinates (px) of the bottom left of a character in an input, relative to the
|
10 |
| - * top-left corner of the input itself. |
11 |
| - * @param input The target input element. |
12 |
| - * @param index The index of the character to calculate for. |
13 |
| - * @param adjustForScroll Control whether the returned value is adjusted based on scroll position. |
| 56 | + * top-left corner of the interior of the input (not adjusted for scroll). |
| 57 | + * |
| 58 | + * Adapted from https://github.com/koddsson/textarea-caret-position, which was itself |
| 59 | + * forked from https://github.com/component/textarea-caret-position. |
| 60 | + * |
| 61 | + * @param element The target input element. |
| 62 | + * @param index The index of the character to calculate. |
14 | 63 | */
|
15 |
| -export const getCharacterCoordinates = ( |
16 |
| - input: HTMLTextAreaElement | HTMLInputElement | null, |
17 |
| - index: number, |
18 |
| - adjustForScroll = true |
19 |
| -): Coordinates => { |
20 |
| - if (!input) return {top: 0, left: 0} |
21 |
| - |
22 |
| - // word-wrap:break-word breaks the getCaretCoordinates calculations (a bug), and word-wrap has |
23 |
| - // no effect on input element anyway |
24 |
| - if (input instanceof HTMLInputElement) input.style.wordWrap = '' |
25 |
| - |
26 |
| - let coords = getCaretCoordinates(input, index) |
27 |
| - |
28 |
| - // The library calls parseInt on the computed line-height of the element, failing to account for |
29 |
| - // the possibility of it being 'normal' (another bug). In that case, fall back to a rough guess |
30 |
| - // of 1.2 based on MDN: "Desktop browsers use a default value of roughly 1.2". |
31 |
| - if (isNaN(coords.height)) coords.height = parseInt(getComputedStyle(input).fontSize) * 1.2 |
32 |
| - |
33 |
| - // Sometimes top is negative, incorrectly, because of the wierd line-height calculations around |
34 |
| - // border-box sized single-line inputs. |
35 |
| - coords.top = Math.abs(coords.top) |
36 |
| - |
37 |
| - // For some single-line inputs, the rightmost character can be accidentally wrapped even with the |
38 |
| - // wordWrap fix above. If this happens, go back to the last usable index |
39 |
| - let adjustedIndex = index |
40 |
| - while (input instanceof HTMLInputElement && coords.top > coords.height) { |
41 |
| - coords = getCaretCoordinates(input, --adjustedIndex) |
| 64 | +export function getCharacterCoordinates( |
| 65 | + element: HTMLTextAreaElement | HTMLInputElement, |
| 66 | + index: number |
| 67 | +): CharacterCoordinates { |
| 68 | + const isFirefox = 'mozInnerScreenX' in window |
| 69 | + |
| 70 | + // The mirror div will replicate the textarea's style |
| 71 | + const div = document.createElement('div') |
| 72 | + div.id = 'input-textarea-caret-position-mirror-div' |
| 73 | + document.body.appendChild(div) |
| 74 | + |
| 75 | + const style = div.style |
| 76 | + const computed = window.getComputedStyle(element) |
| 77 | + |
| 78 | + // Lineheight is either a number or the string 'normal'. In that case, fall back to a |
| 79 | + // rough guess of 1.2 based on MDN: "Desktop browsers use a default value of roughly 1.2". |
| 80 | + const lineHeight = isNaN(parseInt(computed.lineHeight)) |
| 81 | + ? parseInt(computed.fontSize) * 1.2 |
| 82 | + : parseInt(computed.lineHeight) |
| 83 | + |
| 84 | + const isInput = element instanceof HTMLInputElement |
| 85 | + |
| 86 | + // Default wrapping styles |
| 87 | + style.whiteSpace = isInput ? 'nowrap' : 'pre-wrap' |
| 88 | + style.wordWrap = isInput ? '' : 'break-word' |
| 89 | + |
| 90 | + // Position off-screen |
| 91 | + style.position = 'absolute' // required to return coordinates properly |
| 92 | + |
| 93 | + // Transfer the element's properties to the div |
| 94 | + for (const prop of propertiesToCopy) { |
| 95 | + if (isInput && prop === 'lineHeight') { |
| 96 | + // Special case for <input>s because text is rendered centered and line height may be != height |
| 97 | + if (computed.boxSizing === 'border-box') { |
| 98 | + const height = parseInt(computed.height) |
| 99 | + const outerHeight = |
| 100 | + parseInt(computed.paddingTop) + |
| 101 | + parseInt(computed.paddingBottom) + |
| 102 | + parseInt(computed.borderTopWidth) + |
| 103 | + parseInt(computed.borderBottomWidth) |
| 104 | + const targetHeight = outerHeight + lineHeight |
| 105 | + |
| 106 | + if (height > targetHeight) { |
| 107 | + style.lineHeight = `${height - outerHeight}px` |
| 108 | + } else if (height === targetHeight) { |
| 109 | + style.lineHeight = computed.lineHeight |
| 110 | + } else { |
| 111 | + style.lineHeight = '0' |
| 112 | + } |
| 113 | + } else { |
| 114 | + style.lineHeight = computed.height |
| 115 | + } |
| 116 | + } else if (!isInput && prop === 'width' && computed.boxSizing === 'border-box') { |
| 117 | + // With box-sizing: border-box we need to offset the size slightly inwards. This small difference can compound |
| 118 | + // greatly in long textareas with lots of wrapping, leading to very innacurate results if not accounted for. |
| 119 | + // Firefox will return computed styles in floats, like `0.9px`, while chromium might return `1px` for the same element. |
| 120 | + // Either way we use `parseFloat` to turn `0.9px` into `0.9` and `1px` into `1` |
| 121 | + const totalBorderWidth = parseFloat(computed.borderLeftWidth) + parseFloat(computed.borderRightWidth) |
| 122 | + // When a vertical scrollbar is present it shrinks the content. We need to account for this by using clientWidth |
| 123 | + // instead of width in everything but Firefox. When we do that we also have to account for the border width. |
| 124 | + const width = isFirefox ? parseFloat(computed.width) - totalBorderWidth : element.clientWidth + totalBorderWidth |
| 125 | + style.width = `${width}px` |
| 126 | + } else { |
| 127 | + style[prop] = computed[prop] |
| 128 | + } |
| 129 | + } |
| 130 | + |
| 131 | + if (isFirefox) { |
| 132 | + // Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275 |
| 133 | + if (element.scrollHeight > parseInt(computed.height)) style.overflowY = 'scroll' |
| 134 | + } else { |
| 135 | + style.overflow = 'hidden' // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll' |
42 | 136 | }
|
43 | 137 |
|
44 |
| - const scrollTopOffset = adjustForScroll ? -input.scrollTop : 0 |
45 |
| - const scrollLeftOffset = adjustForScroll ? -input.scrollLeft : 0 |
| 138 | + div.textContent = element.value.substring(0, index) |
| 139 | + |
| 140 | + // The second special handling for input type="text" vs textarea: |
| 141 | + // spaces need to be replaced with non-breaking spaces - http://stackoverflow.com/a/13402035/1269037 |
| 142 | + if (isInput) div.textContent = div.textContent.replace(/\s/g, '\u00a0') |
| 143 | + |
| 144 | + const span = document.createElement('span') |
| 145 | + // Wrapping must be replicated *exactly*, including when a long word gets |
| 146 | + // onto the next line, with whitespace at the end of the line before (#7). |
| 147 | + // The *only* reliable way to do that is to copy the *entire* rest of the |
| 148 | + // textarea's content into the <span> created at the caret position. |
| 149 | + // For inputs, '.' is enough because there is no wrapping. |
| 150 | + span.textContent = isInput ? '.' : element.value.substring(index) || '.' // because a completely empty faux span doesn't render at all |
| 151 | + div.appendChild(span) |
| 152 | + |
| 153 | + const coordinates = { |
| 154 | + top: span.offsetTop + parseInt(computed.borderTopWidth), |
| 155 | + left: span.offsetLeft + parseInt(computed.borderLeftWidth), |
| 156 | + height: lineHeight |
| 157 | + } |
| 158 | + |
| 159 | + document.body.removeChild(div) |
| 160 | + |
| 161 | + return coordinates |
| 162 | +} |
46 | 163 |
|
47 |
| - return {top: coords.top + coords.height + scrollTopOffset, left: coords.left + scrollLeftOffset} |
| 164 | +/** |
| 165 | + * Obtain the coordinates (px) of the bottom left of a character in an input, relative to the |
| 166 | + * top-left corner of the input element (adjusted for scroll). This includes horizontal |
| 167 | + * scroll in single-line inputs. |
| 168 | + * @param input The target input element. |
| 169 | + * @param index The index of the character to calculate for. |
| 170 | + */ |
| 171 | +export const getScrollAdjustedCharacterCoordinates = ( |
| 172 | + input: HTMLTextAreaElement | HTMLInputElement, |
| 173 | + index: number |
| 174 | +): CharacterCoordinates => { |
| 175 | + const {height, top, left} = getCharacterCoordinates(input, index) |
| 176 | + |
| 177 | + return {height, top: top - input.scrollTop, left: left - input.scrollLeft} |
48 | 178 | }
|
49 | 179 |
|
50 | 180 | /**
|
51 |
| - * Obtain the coordinates of the bottom left of a character in an input relative to the top-left |
52 |
| - * of the page. |
| 181 | + * Obtain the coordinates (px) of the bottom left of a character in an input, relative to the |
| 182 | + * top-left corner of the document. Since this is relative to the document, it is also adjusted |
| 183 | + * for the input's scroll. |
53 | 184 | * @param input The target input element.
|
54 | 185 | * @param index The index of the character to calculate for.
|
55 | 186 | */
|
56 | 187 | export const getAbsoluteCharacterCoordinates = (
|
57 |
| - input: HTMLTextAreaElement | HTMLInputElement | null, |
| 188 | + input: HTMLTextAreaElement | HTMLInputElement, |
58 | 189 | index: number
|
59 |
| -): Coordinates => { |
60 |
| - const {top: relativeTop, left: relativeLeft} = getCharacterCoordinates(input, index, true) |
61 |
| - const {top: viewportOffsetTop, left: viewportOffsetLeft} = input?.getBoundingClientRect() ?? {top: 0, left: 0} |
| 190 | +): CharacterCoordinates => { |
| 191 | + const {top: relativeTop, left: relativeLeft, height} = getScrollAdjustedCharacterCoordinates(input, index) |
| 192 | + const {top: viewportOffsetTop, left: viewportOffsetLeft} = input.getBoundingClientRect() |
62 | 193 |
|
63 | 194 | return {
|
| 195 | + height, |
64 | 196 | top: viewportOffsetTop + relativeTop,
|
65 | 197 | left: viewportOffsetLeft + relativeLeft
|
66 | 198 | }
|
|
0 commit comments