UNPKG

@primer/react

Version:

An implementation of GitHub's Primer Design System using React

157 lines (143 loc) 7.23 kB
// 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' // prefixed version for Firefox <= 52 ]; /** * 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, index) { 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 <input>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' } 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 <span> 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; } /** * 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. */ const getScrollAdjustedCharacterCoordinates = (input, index) => { const { height, top, left } = getCharacterCoordinates(input, index); 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. 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. */ const getAbsoluteCharacterCoordinates = (input, index) => { const { top: relativeTop, left: relativeLeft, height } = getScrollAdjustedCharacterCoordinates(input, index); const { top: viewportOffsetTop, left: viewportOffsetLeft } = input.getBoundingClientRect(); return { height, top: viewportOffsetTop + relativeTop, left: viewportOffsetLeft + relativeLeft }; }; export { getAbsoluteCharacterCoordinates, getCharacterCoordinates, getScrollAdjustedCharacterCoordinates };