diff --git a/.changeset/inline-textarea-caret.md b/.changeset/inline-textarea-caret.md new file mode 100644 index 00000000000..5955ce039a8 --- /dev/null +++ b/.changeset/inline-textarea-caret.md @@ -0,0 +1,5 @@ +--- +"@primer/react": patch +--- + +Inline the `@koddson/textarea-caret` dependency to fix non-ESM builds diff --git a/@types/@koddsson/index.d.ts b/@types/@koddsson/index.d.ts deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/@types/@koddsson/textarea-caret/index.d.ts b/@types/@koddsson/textarea-caret/index.d.ts deleted file mode 100644 index c2505997701..00000000000 --- a/@types/@koddsson/textarea-caret/index.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -declare module '@koddsson/textarea-caret' { - export interface CaretCoordinates { - top: number - left: number - height: number - } - export default function getCaretCoordinates( - input: HTMLTextAreaElement | HTMLInputElement, - index: number - ): CaretCoordinates -} diff --git a/package-lock.json b/package-lock.json index ab04a5204c1..f3e4535a9a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@github/combobox-nav": "^2.1.5", "@github/markdown-toolbar-element": "^2.1.0", "@github/paste-markdown": "^1.3.1", - "@koddsson/textarea-caret": "^4.0.1", "@primer/behaviors": "^1.1.1", "@primer/octicons-react": "^17.3.0", "@primer/primitives": "7.9.0", @@ -5336,11 +5335,6 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "node_modules/@koddsson/textarea-caret": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@koddsson/textarea-caret/-/textarea-caret-4.0.1.tgz", - "integrity": "sha512-KaHkM8WX2VCNcCzg7Q83aBcWhpCTkC/olARZbvSbQtAQPK+zXutLBhNNtpPgGL6ELXlA27tiD+kMfWyDLs3n+Q==" - }, "node_modules/@manypkg/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-1.1.0.tgz", @@ -41363,11 +41357,6 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, - "@koddsson/textarea-caret": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@koddsson/textarea-caret/-/textarea-caret-4.0.1.tgz", - "integrity": "sha512-KaHkM8WX2VCNcCzg7Q83aBcWhpCTkC/olARZbvSbQtAQPK+zXutLBhNNtpPgGL6ELXlA27tiD+kMfWyDLs3n+Q==" - }, "@manypkg/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-1.1.0.tgz", diff --git a/package.json b/package.json index 9fc1ca7ac67..16f44119b5a 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,6 @@ "@github/combobox-nav": "^2.1.5", "@github/markdown-toolbar-element": "^2.1.0", "@github/paste-markdown": "^1.3.1", - "@koddsson/textarea-caret": "^4.0.1", "@primer/behaviors": "^1.1.1", "@primer/octicons-react": "^17.3.0", "@primer/primitives": "7.9.0", diff --git a/src/drafts/InlineAutocomplete/InlineAutocomplete.tsx b/src/drafts/InlineAutocomplete/InlineAutocomplete.tsx index 2b1541a325a..bbc537867e9 100644 --- a/src/drafts/InlineAutocomplete/InlineAutocomplete.tsx +++ b/src/drafts/InlineAutocomplete/InlineAutocomplete.tsx @@ -101,14 +101,14 @@ const InlineAutocomplete = ({ // optimized by only re-rendering when suggestionsVisible changes. However, the user // could move the cursor to a different location using arrow keys and then type a // trigger, which would move the suggestions without closing/reopening them. - const suggestionsOffset = + const triggerCharCoords = inputRef.current && showEventRef.current && suggestionsVisible ? getAbsoluteCharacterCoordinates( inputRef.current, - // Position the suggestions at the trigger character, not the current caret position (getSelectionStart(inputRef.current) ?? 0) - showEventRef.current.query.length ) - : {top: 0, left: 0} + : {top: 0, left: 0, height: 0} + const suggestionsOffset = {top: triggerCharCoords.top + triggerCharCoords.height, left: triggerCharCoords.left} // User can blur while suggestions are visible with shift+tab const onBlur: React.FocusEventHandler = () => { diff --git a/src/drafts/hooks/useDynamicTextareaHeight.ts b/src/drafts/hooks/useDynamicTextareaHeight.ts index 7f1164f14aa..13b295bcb67 100644 --- a/src/drafts/hooks/useDynamicTextareaHeight.ts +++ b/src/drafts/hooks/useDynamicTextareaHeight.ts @@ -36,16 +36,19 @@ export const useDynamicTextareaHeight = ({ const computedStyles = getComputedStyle(element) const pt = computedStyles.paddingTop + const lastCharacterCoords = getCharacterCoordinates(element, element.value.length) // The calculator gives us the distance from the top border to the bottom of the caret, including // any top padding, so we need to delete the top padding to accurately get the height + // We could also parse and subtract the top padding, but this is more reliable (no chance of NaN) element.style.paddingTop = '0' // Somehow we come up 1 pixel too short and the scrollbar appears, so just add one - setHeight(`${getCharacterCoordinates(element, element.value.length, false).top + 1}px`) + setHeight(`${lastCharacterCoords.top + lastCharacterCoords.height + 1}px`) element.style.paddingTop = pt const lineHeight = computedStyles.lineHeight === 'normal' ? `1.2 * ${computedStyles.fontSize}` : computedStyles.lineHeight + // Using CSS calculations is fast and prevents us from having to parse anything setMinHeight(`calc(${minHeightLines} * ${lineHeight})`) setMaxHeight(`calc(${maxHeightLines} * ${lineHeight})`) // `value` is an unnecessary dependency but it enables us to recalculate as the user types diff --git a/src/drafts/utils/character-coordinates.ts b/src/drafts/utils/character-coordinates.ts index 7fb0e7af448..f28690da09e 100644 --- a/src/drafts/utils/character-coordinates.ts +++ b/src/drafts/utils/character-coordinates.ts @@ -1,66 +1,198 @@ -import getCaretCoordinates from '@koddsson/textarea-caret' - -export type Coordinates = { +export type CharacterCoordinates = { + /** Number of pixels from the origin down to the top edge of the character. */ top: number + /** Number of pixels from the origin right to the left edge of the character. */ left: number + /** Height of the character. */ + height: number } +// 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 propertiesToCopy = [ + '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' as 'tabSize' // prefixed version for Firefox <= 52 +] as const + /** * Obtain the coordinates (px) of the bottom left of a character in an input, relative to the - * top-left corner of the input itself. - * @param input The target input element. - * @param index The index of the character to calculate for. - * @param adjustForScroll Control whether the returned value is adjusted based on scroll position. + * top-left corner of the interior of the input (not adjusted for scroll). + * + * Adapted from https://github.com/koddsson/textarea-caret-position, which was itself + * forked from https://github.com/component/textarea-caret-position. + * + * @param element The target input element. + * @param index The index of the character to calculate. */ -export const getCharacterCoordinates = ( - input: HTMLTextAreaElement | HTMLInputElement | null, - index: number, - adjustForScroll = true -): Coordinates => { - if (!input) return {top: 0, left: 0} - - // word-wrap:break-word breaks the getCaretCoordinates calculations (a bug), and word-wrap has - // no effect on input element anyway - if (input instanceof HTMLInputElement) input.style.wordWrap = '' - - let coords = getCaretCoordinates(input, index) - - // The library calls parseInt on the computed line-height of the element, failing to account for - // the possibility of it being 'normal' (another bug). In that case, fall back to a rough guess - // of 1.2 based on MDN: "Desktop browsers use a default value of roughly 1.2". - if (isNaN(coords.height)) coords.height = parseInt(getComputedStyle(input).fontSize) * 1.2 - - // Sometimes top is negative, incorrectly, because of the wierd line-height calculations around - // border-box sized single-line inputs. - coords.top = Math.abs(coords.top) - - // For some single-line inputs, the rightmost character can be accidentally wrapped even with the - // wordWrap fix above. If this happens, go back to the last usable index - let adjustedIndex = index - while (input instanceof HTMLInputElement && coords.top > coords.height) { - coords = getCaretCoordinates(input, --adjustedIndex) +export function getCharacterCoordinates( + element: HTMLTextAreaElement | HTMLInputElement, + index: number +): CharacterCoordinates { + const isFirefox = 'mozInnerScreenX' in window + + // The mirror div will replicate the textarea's 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(element) + + // Lineheight is either a number or the string 'normal'. In that case, fall back to a + // rough guess of 1.2 based on MDN: "Desktop browsers use a default value of roughly 1.2". + const lineHeight = isNaN(parseInt(computed.lineHeight)) + ? parseInt(computed.fontSize) * 1.2 + : parseInt(computed.lineHeight) + + const isInput = element instanceof HTMLInputElement + + // Default wrapping styles + style.whiteSpace = isInput ? 'nowrap' : 'pre-wrap' + style.wordWrap = isInput ? '' : 'break-word' + + // Position off-screen + style.position = 'absolute' // required to return coordinates properly + + // Transfer the element's properties to the div + for (const prop of propertiesToCopy) { + if (isInput && prop === 'lineHeight') { + // Special case for s because text is rendered centered and line height may be != height + if (computed.boxSizing === 'border-box') { + const height = parseInt(computed.height) + const outerHeight = + parseInt(computed.paddingTop) + + parseInt(computed.paddingBottom) + + parseInt(computed.borderTopWidth) + + parseInt(computed.borderBottomWidth) + const targetHeight = outerHeight + lineHeight + + 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 if (!isInput && prop === 'width' && computed.boxSizing === 'border-box') { + // With box-sizing: border-box we need to offset the size slightly inwards. This small difference can compound + // greatly in long textareas with lots of wrapping, leading to very innacurate results if not accounted for. + // Firefox will return computed styles in floats, like `0.9px`, while chromium might return `1px` for the same element. + // Either way we use `parseFloat` to turn `0.9px` into `0.9` and `1px` into `1` + const totalBorderWidth = parseFloat(computed.borderLeftWidth) + parseFloat(computed.borderRightWidth) + // When a vertical scrollbar is present it shrinks the content. We need to account for this by using clientWidth + // instead of width in everything but Firefox. When we do that we also have to account for the border width. + const width = isFirefox ? parseFloat(computed.width) - totalBorderWidth : element.clientWidth + totalBorderWidth + style.width = `${width}px` + } else { + style[prop] = computed[prop] + } + } + + if (isFirefox) { + // Firefox lies about the overflow property for textareas: https://bugzilla.mozilla.org/show_bug.cgi?id=984275 + if (element.scrollHeight > parseInt(computed.height)) style.overflowY = 'scroll' + } else { + style.overflow = 'hidden' // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll' } - const scrollTopOffset = adjustForScroll ? -input.scrollTop : 0 - const scrollLeftOffset = adjustForScroll ? -input.scrollLeft : 0 + div.textContent = element.value.substring(0, index) + + // 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 = div.textContent.replace(/\s/g, '\u00a0') + + 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 + // textarea's content into the created at the caret position. + // For inputs, '.' is enough because there is no wrapping. + span.textContent = isInput ? '.' : element.value.substring(index) || '.' // because a completely empty faux span doesn't render at all + div.appendChild(span) + + const coordinates = { + top: span.offsetTop + parseInt(computed.borderTopWidth), + left: span.offsetLeft + parseInt(computed.borderLeftWidth), + height: lineHeight + } + + document.body.removeChild(div) + + return coordinates +} - return {top: coords.top + coords.height + scrollTopOffset, left: coords.left + scrollLeftOffset} +/** + * Obtain the coordinates (px) of the bottom left of a character in an input, relative to the + * top-left corner of the input element (adjusted for scroll). This includes horizontal + * scroll in single-line inputs. + * @param input The target input element. + * @param index The index of the character to calculate for. + */ +export const getScrollAdjustedCharacterCoordinates = ( + input: HTMLTextAreaElement | HTMLInputElement, + index: number +): CharacterCoordinates => { + const {height, top, left} = getCharacterCoordinates(input, index) + + return {height, top: top - input.scrollTop, left: left - input.scrollLeft} } /** - * Obtain the coordinates of the bottom left of a character in an input relative to the top-left - * of the page. + * Obtain the coordinates (px) of the bottom left of a character in an input, relative to the + * top-left corner of the document. Since this is relative to the document, it is also adjusted + * for the input's scroll. * @param input The target input element. * @param index The index of the character to calculate for. */ export const getAbsoluteCharacterCoordinates = ( - input: HTMLTextAreaElement | HTMLInputElement | null, + input: HTMLTextAreaElement | HTMLInputElement, index: number -): Coordinates => { - const {top: relativeTop, left: relativeLeft} = getCharacterCoordinates(input, index, true) - const {top: viewportOffsetTop, left: viewportOffsetLeft} = input?.getBoundingClientRect() ?? {top: 0, left: 0} +): CharacterCoordinates => { + const {top: relativeTop, left: relativeLeft, height} = getScrollAdjustedCharacterCoordinates(input, index) + const {top: viewportOffsetTop, left: viewportOffsetLeft} = input.getBoundingClientRect() return { + height, top: viewportOffsetTop + relativeTop, left: viewportOffsetLeft + relativeLeft }