From 4f40c9b6a8c6d52a6f710bcf6aaae9ecde2206e0 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Wed, 17 Aug 2022 21:08:38 +0000 Subject: [PATCH 1/9] Inline the code from `@koddsson/textarea-caret` --- @types/@koddsson/index.d.ts | 0 @types/@koddsson/textarea-caret/index.d.ts | 11 -- package.json | 1 - src/drafts/utils/character-coordinates.ts | 153 ++++++++++++++++++++- 4 files changed, 152 insertions(+), 13 deletions(-) delete mode 100644 @types/@koddsson/index.d.ts delete mode 100644 @types/@koddsson/textarea-caret/index.d.ts 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.json b/package.json index 6be803dab9c..530d7d1939c 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/utils/character-coordinates.ts b/src/drafts/utils/character-coordinates.ts index 7fb0e7af448..492cea4a20a 100644 --- a/src/drafts/utils/character-coordinates.ts +++ b/src/drafts/utils/character-coordinates.ts @@ -1,4 +1,155 @@ -import getCaretCoordinates from '@koddsson/textarea-caret' +// Modified from https://github.com/koddsson/textarea-caret-position, which was +// itself forked from https://github.com/component/textarea-caret-position. + +// 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' +] as const + +function getCaretCoordinates( + element: HTMLTextAreaElement | HTMLInputElement, + position: number, + options?: {debug: boolean} +) { + const debug = (options && options.debug) || false + if (debug) { + const el = document.querySelector('#input-textarea-caret-position-mirror-div') + if (el) el.parentNode?.removeChild(el) + } + + 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 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + const computed = window.getComputedStyle + ? window.getComputedStyle(element) + : (element as unknown as {currentStyle: CSSStyleDeclaration}).currentStyle // currentStyle for IE < 9 + const isInput = element.nodeName === 'INPUT' + + // Default textarea styles + style.whiteSpace = '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 + 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 + parseInt(computed.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[prop]) - totalBorderWidth : element.clientWidth + totalBorderWidth + style[prop] = `${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' + } + + 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 = 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, 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 + div.appendChild(span) + + const coordinates = { + top: span.offsetTop + parseInt(computed['borderTopWidth']), + left: span.offsetLeft + parseInt(computed['borderLeftWidth']), + height: parseInt(computed['lineHeight']) + } + + if (debug) { + span.style.backgroundColor = '#aaa' + } else { + document.body.removeChild(div) + } + + return coordinates +} export type Coordinates = { top: number From faee0b8fca9c9bab17da90f0ee563c91f78ca1d5 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Wed, 17 Aug 2022 21:10:29 +0000 Subject: [PATCH 2/9] Remove workaround for IE9 --- src/drafts/utils/character-coordinates.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/drafts/utils/character-coordinates.ts b/src/drafts/utils/character-coordinates.ts index 492cea4a20a..8ee962a69c8 100644 --- a/src/drafts/utils/character-coordinates.ts +++ b/src/drafts/utils/character-coordinates.ts @@ -64,10 +64,7 @@ function getCaretCoordinates( document.body.appendChild(div) const style = div.style - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - const computed = window.getComputedStyle - ? window.getComputedStyle(element) - : (element as unknown as {currentStyle: CSSStyleDeclaration}).currentStyle // currentStyle for IE < 9 + const computed = window.getComputedStyle(element) const isInput = element.nodeName === 'INPUT' // Default textarea styles From 8fe6d0669e03788b2205f856262dfcd49d812a63 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Wed, 17 Aug 2022 21:12:02 +0000 Subject: [PATCH 3/9] Add comment for Firefox workaround --- src/drafts/utils/character-coordinates.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/drafts/utils/character-coordinates.ts b/src/drafts/utils/character-coordinates.ts index 8ee962a69c8..ace4d8deb72 100644 --- a/src/drafts/utils/character-coordinates.ts +++ b/src/drafts/utils/character-coordinates.ts @@ -42,7 +42,7 @@ const propertiesToCopy = [ 'wordSpacing', 'tabSize', - 'MozTabSize' as 'tabSize' + 'MozTabSize' as 'tabSize' // prefixed version for Firefox <= 52 ] as const function getCaretCoordinates( From 1722250dc6228c486e6dcfae5ca32df48662ea2a Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Thu, 18 Aug 2022 15:25:01 +0000 Subject: [PATCH 4/9] Integrate bug fixes into the modified function --- package-lock.json | 15 +-- src/drafts/hooks/useDynamicTextareaHeight.ts | 4 +- src/drafts/utils/character-coordinates.ts | 103 +++++++++---------- 3 files changed, 52 insertions(+), 70 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2b5d3568cd4..7037f33616f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,17 @@ { "name": "@primer/react", - "version": "35.5.0", + "version": "35.6.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@primer/react", - "version": "35.5.0", + "version": "35.6.0", "license": "MIT", "dependencies": { "@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", @@ -41289,11 +41283,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/src/drafts/hooks/useDynamicTextareaHeight.ts b/src/drafts/hooks/useDynamicTextareaHeight.ts index 7f1164f14aa..4e83cb26d50 100644 --- a/src/drafts/hooks/useDynamicTextareaHeight.ts +++ b/src/drafts/hooks/useDynamicTextareaHeight.ts @@ -1,7 +1,7 @@ import {useLayoutEffect, useState} from 'react' import {SxProp} from '../../sx' -import {getCharacterCoordinates} from '../utils/character-coordinates' +import {getScrollAdjustedCharacterCoordinates} from '../utils/character-coordinates' type UseDynamicTextareaHeightSettings = { minHeightLines: number @@ -41,7 +41,7 @@ export const useDynamicTextareaHeight = ({ // any top padding, so we need to delete the top padding to accurately get the height 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(`${getScrollAdjustedCharacterCoordinates(element, element.value.length, false).top + 1}px`) element.style.paddingTop = pt const lineHeight = diff --git a/src/drafts/utils/character-coordinates.ts b/src/drafts/utils/character-coordinates.ts index ace4d8deb72..9a8b67b9242 100644 --- a/src/drafts/utils/character-coordinates.ts +++ b/src/drafts/utils/character-coordinates.ts @@ -1,5 +1,9 @@ -// Modified from https://github.com/koddsson/textarea-caret-position, which was -// itself forked from https://github.com/component/textarea-caret-position. +export type Coordinates = { + /** Number of pixels from the origin down to the bottom edge of the character. */ + top: number + /** Number of pixels from the origin right to the left edge of the character. */ + left: number +} // Note that some browsers, such as Firefox, do not concatenate properties // into their shorthand (e.g. padding-top, padding-bottom etc. -> padding), @@ -45,9 +49,19 @@ const propertiesToCopy = [ 'MozTabSize' as 'tabSize' // prefixed version for Firefox <= 52 ] as const -function getCaretCoordinates( +/** + * Obtain the coordinates (px) of the bottom left of a character in an input, relative to the + * 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. + */ +function getCharacterCoordinates( element: HTMLTextAreaElement | HTMLInputElement, - position: number, + index: number, options?: {debug: boolean} ) { const debug = (options && options.debug) || false @@ -65,11 +79,18 @@ function getCaretCoordinates( const style = div.style const computed = window.getComputedStyle(element) - const isInput = element.nodeName === 'INPUT' - // Default textarea styles - style.whiteSpace = 'pre-wrap' - if (!isInput) style.wordWrap = 'break-word' // only for textarea-s + // 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 @@ -86,7 +107,8 @@ function getCaretCoordinates( parseInt(computed.paddingBottom) + parseInt(computed.borderTopWidth) + parseInt(computed.borderBottomWidth) - const targetHeight = outerHeight + parseInt(computed.lineHeight) + const targetHeight = outerHeight + lineHeight + if (height > targetHeight) { style.lineHeight = `${height - outerHeight}px` } else if (height === targetHeight) { @@ -105,8 +127,8 @@ function getCaretCoordinates( 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[prop]) - totalBorderWidth : element.clientWidth + totalBorderWidth - style[prop] = `${width}px` + const width = isFirefox ? parseFloat(computed.width) - totalBorderWidth : element.clientWidth + totalBorderWidth + style.width = `${width}px` } else { style[prop] = computed[prop] } @@ -119,7 +141,8 @@ function getCaretCoordinates( style.overflow = 'hidden' // for Chrome to not render a scrollbar; IE keeps overflowY = 'scroll' } - div.textContent = element.value.substring(0, position) + 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') @@ -129,14 +152,14 @@ function getCaretCoordinates( // 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, 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 + // 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: parseInt(computed['lineHeight']) + top: span.offsetTop + parseInt(computed.borderTopWidth), + left: span.offsetLeft + parseInt(computed.borderLeftWidth), + height: lineHeight } if (debug) { @@ -148,56 +171,26 @@ function getCaretCoordinates( return coordinates } -export type Coordinates = { - top: number - left: number -} - /** * Obtain the coordinates (px) of the bottom left of a character in an input, relative to the - * top-left corner of the input itself. + * top-left corner of the input element (adjusted for scroll). * @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. */ -export const getCharacterCoordinates = ( +export const getScrollAdjustedCharacterCoordinates = ( input: HTMLTextAreaElement | HTMLInputElement | null, - index: number, - adjustForScroll = true + index: number ): 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) + const coords = getCharacterCoordinates(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) - } - - const scrollTopOffset = adjustForScroll ? -input.scrollTop : 0 - const scrollLeftOffset = adjustForScroll ? -input.scrollLeft : 0 - - return {top: coords.top + coords.height + scrollTopOffset, left: coords.left + scrollLeftOffset} + return {top: coords.top + coords.height - input.scrollTop, left: coords.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. * @param input The target input element. * @param index The index of the character to calculate for. */ @@ -205,7 +198,7 @@ export const getAbsoluteCharacterCoordinates = ( input: HTMLTextAreaElement | HTMLInputElement | null, index: number ): Coordinates => { - const {top: relativeTop, left: relativeLeft} = getCharacterCoordinates(input, index, true) + const {top: relativeTop, left: relativeLeft} = getScrollAdjustedCharacterCoordinates(input, index) const {top: viewportOffsetTop, left: viewportOffsetLeft} = input?.getBoundingClientRect() ?? {top: 0, left: 0} return { From 147ad630d0e93bbd6c60158d2c93765b533ef08b Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Thu, 18 Aug 2022 15:27:02 +0000 Subject: [PATCH 5/9] Remove `debug` option --- src/drafts/utils/character-coordinates.ts | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/src/drafts/utils/character-coordinates.ts b/src/drafts/utils/character-coordinates.ts index 9a8b67b9242..5e07aed857a 100644 --- a/src/drafts/utils/character-coordinates.ts +++ b/src/drafts/utils/character-coordinates.ts @@ -59,17 +59,7 @@ const propertiesToCopy = [ * @param element The target input element. * @param index The index of the character to calculate. */ -function getCharacterCoordinates( - element: HTMLTextAreaElement | HTMLInputElement, - index: number, - options?: {debug: boolean} -) { - const debug = (options && options.debug) || false - if (debug) { - const el = document.querySelector('#input-textarea-caret-position-mirror-div') - if (el) el.parentNode?.removeChild(el) - } - +function getCharacterCoordinates(element: HTMLTextAreaElement | HTMLInputElement, index: number) { const isFirefox = 'mozInnerScreenX' in window // The mirror div will replicate the textarea's style @@ -94,7 +84,6 @@ function getCharacterCoordinates( // 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 for (const prop of propertiesToCopy) { @@ -162,11 +151,7 @@ function getCharacterCoordinates( height: lineHeight } - if (debug) { - span.style.backgroundColor = '#aaa' - } else { - document.body.removeChild(div) - } + document.body.removeChild(div) return coordinates } From 3d6c6b564f9e729656e33918197c95ba59340c98 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Thu, 18 Aug 2022 15:43:04 +0000 Subject: [PATCH 6/9] Update character coordinates utils to return `height` --- .../InlineAutocomplete/InlineAutocomplete.tsx | 6 ++-- src/drafts/hooks/useDynamicTextareaHeight.ts | 7 +++-- src/drafts/utils/character-coordinates.ts | 30 ++++++++++++------- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/drafts/InlineAutocomplete/InlineAutocomplete.tsx b/src/drafts/InlineAutocomplete/InlineAutocomplete.tsx index 2b1541a325a..1a8e1504974 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: 0} // 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 4e83cb26d50..13b295bcb67 100644 --- a/src/drafts/hooks/useDynamicTextareaHeight.ts +++ b/src/drafts/hooks/useDynamicTextareaHeight.ts @@ -1,7 +1,7 @@ import {useLayoutEffect, useState} from 'react' import {SxProp} from '../../sx' -import {getScrollAdjustedCharacterCoordinates} from '../utils/character-coordinates' +import {getCharacterCoordinates} from '../utils/character-coordinates' type UseDynamicTextareaHeightSettings = { minHeightLines: number @@ -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(`${getScrollAdjustedCharacterCoordinates(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 5e07aed857a..ec9ee6f65bd 100644 --- a/src/drafts/utils/character-coordinates.ts +++ b/src/drafts/utils/character-coordinates.ts @@ -1,8 +1,10 @@ -export type Coordinates = { - /** Number of pixels from the origin down to the bottom edge of the character. */ +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 @@ -59,7 +61,10 @@ const propertiesToCopy = [ * @param element The target input element. * @param index The index of the character to calculate. */ -function getCharacterCoordinates(element: HTMLTextAreaElement | HTMLInputElement, index: number) { +export function getCharacterCoordinates( + element: HTMLTextAreaElement | HTMLInputElement, + index: number +): CharacterCoordinates { const isFirefox = 'mozInnerScreenX' in window // The mirror div will replicate the textarea's style @@ -158,35 +163,38 @@ function getCharacterCoordinates(element: HTMLTextAreaElement | HTMLInputElement /** * 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). + * 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 | null, index: number -): Coordinates => { - if (!input) return {top: 0, left: 0} +): CharacterCoordinates => { + if (!input) return {height: 0, top: 0, left: 0} - const coords = getCharacterCoordinates(input, index) + const {height, top, left} = getCharacterCoordinates(input, index) - return {top: coords.top + coords.height - input.scrollTop, left: coords.left - input.scrollLeft} + return {height, top: top - input.scrollTop, left: left - input.scrollLeft} } /** * Obtain the coordinates (px) of the bottom left of a character in an input, relative to the - * top-left corner of the document. + * 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, index: number -): Coordinates => { - const {top: relativeTop, left: relativeLeft} = getScrollAdjustedCharacterCoordinates(input, index) +): CharacterCoordinates => { + const {top: relativeTop, left: relativeLeft, height} = getScrollAdjustedCharacterCoordinates(input, index) const {top: viewportOffsetTop, left: viewportOffsetLeft} = input?.getBoundingClientRect() ?? {top: 0, left: 0} return { + height, top: viewportOffsetTop + relativeTop, left: viewportOffsetLeft + relativeLeft } From d0b65734a5c013dbd264dd7d3cc67717a3835af9 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Thu, 18 Aug 2022 15:43:58 +0000 Subject: [PATCH 7/9] Make `element` params non-nullable --- src/drafts/utils/character-coordinates.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/drafts/utils/character-coordinates.ts b/src/drafts/utils/character-coordinates.ts index ec9ee6f65bd..f28690da09e 100644 --- a/src/drafts/utils/character-coordinates.ts +++ b/src/drafts/utils/character-coordinates.ts @@ -169,11 +169,9 @@ export function getCharacterCoordinates( * @param index The index of the character to calculate for. */ export const getScrollAdjustedCharacterCoordinates = ( - input: HTMLTextAreaElement | HTMLInputElement | null, + input: HTMLTextAreaElement | HTMLInputElement, index: number ): CharacterCoordinates => { - if (!input) return {height: 0, top: 0, left: 0} - const {height, top, left} = getCharacterCoordinates(input, index) return {height, top: top - input.scrollTop, left: left - input.scrollLeft} @@ -187,11 +185,11 @@ export const getScrollAdjustedCharacterCoordinates = ( * @param index The index of the character to calculate for. */ export const getAbsoluteCharacterCoordinates = ( - input: HTMLTextAreaElement | HTMLInputElement | null, + input: HTMLTextAreaElement | HTMLInputElement, index: number ): CharacterCoordinates => { const {top: relativeTop, left: relativeLeft, height} = getScrollAdjustedCharacterCoordinates(input, index) - const {top: viewportOffsetTop, left: viewportOffsetLeft} = input?.getBoundingClientRect() ?? {top: 0, left: 0} + const {top: viewportOffsetTop, left: viewportOffsetLeft} = input.getBoundingClientRect() return { height, From 1a12f4a0991879d523ba93bb24e391b39836db2e Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Thu, 18 Aug 2022 15:47:16 +0000 Subject: [PATCH 8/9] Fix left positioning of suggestions --- src/drafts/InlineAutocomplete/InlineAutocomplete.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/drafts/InlineAutocomplete/InlineAutocomplete.tsx b/src/drafts/InlineAutocomplete/InlineAutocomplete.tsx index 1a8e1504974..bbc537867e9 100644 --- a/src/drafts/InlineAutocomplete/InlineAutocomplete.tsx +++ b/src/drafts/InlineAutocomplete/InlineAutocomplete.tsx @@ -108,7 +108,7 @@ const InlineAutocomplete = ({ (getSelectionStart(inputRef.current) ?? 0) - showEventRef.current.query.length ) : {top: 0, left: 0, height: 0} - const suggestionsOffset = {top: triggerCharCoords.top + triggerCharCoords.height, left: 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 = () => { From 4dcf0fc4bdc4eea00f12994e61c43c6be131fe11 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Thu, 18 Aug 2022 11:57:13 -0400 Subject: [PATCH 9/9] Create inline-textarea-caret.md --- .changeset/inline-textarea-caret.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/inline-textarea-caret.md 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