Skip to content

Commit cf6f0e6

Browse files
Fix @koddson/textarea-caret breaking non-ESM builds (#2244)
* Inline the code from `@koddsson/textarea-caret` * Remove workaround for IE9 * Add comment for Firefox workaround * Integrate bug fixes into the modified function * Remove `debug` option * Update character coordinates utils to return `height` * Make `element` params non-nullable * Fix left positioning of suggestions * Create inline-textarea-caret.md Co-authored-by: Cole Bemis <colebemis@github.com>
1 parent bf99db9 commit cf6f0e6

File tree

8 files changed

+187
-70
lines changed

8 files changed

+187
-70
lines changed

.changeset/inline-textarea-caret.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/react": patch
3+
---
4+
5+
Inline the `@koddson/textarea-caret` dependency to fix non-ESM builds

@types/@koddsson/index.d.ts

Whitespace-only changes.

@types/@koddsson/textarea-caret/index.d.ts

Lines changed: 0 additions & 11 deletions
This file was deleted.

package-lock.json

Lines changed: 0 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,6 @@
8383
"@github/combobox-nav": "^2.1.5",
8484
"@github/markdown-toolbar-element": "^2.1.0",
8585
"@github/paste-markdown": "^1.3.1",
86-
"@koddsson/textarea-caret": "^4.0.1",
8786
"@primer/behaviors": "^1.1.1",
8887
"@primer/octicons-react": "^17.3.0",
8988
"@primer/primitives": "7.9.0",

src/drafts/InlineAutocomplete/InlineAutocomplete.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,14 +101,14 @@ const InlineAutocomplete = ({
101101
// optimized by only re-rendering when suggestionsVisible changes. However, the user
102102
// could move the cursor to a different location using arrow keys and then type a
103103
// trigger, which would move the suggestions without closing/reopening them.
104-
const suggestionsOffset =
104+
const triggerCharCoords =
105105
inputRef.current && showEventRef.current && suggestionsVisible
106106
? getAbsoluteCharacterCoordinates(
107107
inputRef.current,
108-
// Position the suggestions at the trigger character, not the current caret position
109108
(getSelectionStart(inputRef.current) ?? 0) - showEventRef.current.query.length
110109
)
111-
: {top: 0, left: 0}
110+
: {top: 0, left: 0, height: 0}
111+
const suggestionsOffset = {top: triggerCharCoords.top + triggerCharCoords.height, left: triggerCharCoords.left}
112112

113113
// User can blur while suggestions are visible with shift+tab
114114
const onBlur: React.FocusEventHandler<TextInputElement> = () => {

src/drafts/hooks/useDynamicTextareaHeight.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,19 @@ export const useDynamicTextareaHeight = ({
3636

3737
const computedStyles = getComputedStyle(element)
3838
const pt = computedStyles.paddingTop
39+
const lastCharacterCoords = getCharacterCoordinates(element, element.value.length)
3940

4041
// The calculator gives us the distance from the top border to the bottom of the caret, including
4142
// any top padding, so we need to delete the top padding to accurately get the height
43+
// We could also parse and subtract the top padding, but this is more reliable (no chance of NaN)
4244
element.style.paddingTop = '0'
4345
// Somehow we come up 1 pixel too short and the scrollbar appears, so just add one
44-
setHeight(`${getCharacterCoordinates(element, element.value.length, false).top + 1}px`)
46+
setHeight(`${lastCharacterCoords.top + lastCharacterCoords.height + 1}px`)
4547
element.style.paddingTop = pt
4648

4749
const lineHeight =
4850
computedStyles.lineHeight === 'normal' ? `1.2 * ${computedStyles.fontSize}` : computedStyles.lineHeight
51+
// Using CSS calculations is fast and prevents us from having to parse anything
4952
setMinHeight(`calc(${minHeightLines} * ${lineHeight})`)
5053
setMaxHeight(`calc(${maxHeightLines} * ${lineHeight})`)
5154
// `value` is an unnecessary dependency but it enables us to recalculate as the user types

src/drafts/utils/character-coordinates.ts

Lines changed: 175 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,198 @@
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. */
43
top: number
4+
/** Number of pixels from the origin right to the left edge of the character. */
55
left: number
6+
/** Height of the character. */
7+
height: number
68
}
79

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+
854
/**
955
* 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.
1463
*/
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'
42136
}
43137

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+
}
46163

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}
48178
}
49179

50180
/**
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.
53184
* @param input The target input element.
54185
* @param index The index of the character to calculate for.
55186
*/
56187
export const getAbsoluteCharacterCoordinates = (
57-
input: HTMLTextAreaElement | HTMLInputElement | null,
188+
input: HTMLTextAreaElement | HTMLInputElement,
58189
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()
62193

63194
return {
195+
height,
64196
top: viewportOffsetTop + relativeTop,
65197
left: viewportOffsetLeft + relativeLeft
66198
}

0 commit comments

Comments
 (0)