* spelling: access Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: additional Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: administrative Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: aggregate Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: assigns Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: attributes Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: autocomplete Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: because Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: between Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: bootstrap Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: calculating Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: captcha Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: character Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: chosen Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: commenter Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: competitor Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: componentize Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: contrast Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: corresponding Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: description Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: destroyed Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: destroys Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: discussion Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: episode Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: escaped Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: evaluates Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: expired Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: explicitly Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: facebook Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: fragment Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: functionality Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: improper Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: incentive Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: interfere Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: latest Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: message Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: minimum Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: moderator Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: mouseover Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: mutual Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: nonexistent Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: notification Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: occasionally Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: occurrence Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: occurs Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: octokit Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: offset Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: omitted Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: opacity Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: organization Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: organizations Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: overridden Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: override Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: overriding Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: prefill Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: previous Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: profile Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: recycling Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: registered Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: repositories Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: rescuing Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: response Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: returns Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: second Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: separator Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: services Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: subscriber Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: subscription Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: success Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: successful Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: successfully Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: suppress Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: test Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: thought Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: uniqueness Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: unknown Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: unproductive Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: unreachable Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: unsuccessful Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: utilities Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: utility Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: valid Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: voluntarily Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: vomited Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: website Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> * spelling: withholding Signed-off-by: Josh Soref <jsoref@users.noreply.github.com> Co-authored-by: Josh Soref <jsoref@users.noreply.github.com>
298 lines
9.8 KiB
JavaScript
298 lines
9.8 KiB
JavaScript
import { useEffect, useState } from 'preact/hooks';
|
|
import { calculateTextAreaHeight } from '@utilities/calculateTextAreaHeight';
|
|
|
|
/**
|
|
* A helper function to get the X/Y coordinates of the current cursor position within an element.
|
|
* For a full explanation see the post by Jhey Tompkins: https://medium.com/@jh3y/how-to-where-s-the-caret-getting-the-xy-position-of-the-caret-a24ba372990a
|
|
*
|
|
* @param {element} input The DOM element the cursor is to be found within
|
|
* @param {number} selectionPoint The current cursor position (e.g. either selectionStart or selectionEnd)
|
|
*
|
|
* @returns {object} An object with x and y properties (e.g. {x: 10, y: 0})
|
|
*
|
|
* @example
|
|
* const coordinates = getCursorXY(elementRef.current, elementRef.current.selectionStart)
|
|
*/
|
|
export const getCursorXY = (input, selectionPoint) => {
|
|
const bodyRect = document.body.getBoundingClientRect();
|
|
const elementRect = input.getBoundingClientRect();
|
|
|
|
const inputY = elementRect.top - bodyRect.top - input.scrollTop;
|
|
const inputX = elementRect.left - bodyRect.left - input.scrollLeft;
|
|
|
|
// create a dummy element with the computed style of the input
|
|
const div = document.createElement('div');
|
|
const copyStyle = getComputedStyle(input);
|
|
for (const prop of copyStyle) {
|
|
div.style[prop] = copyStyle[prop];
|
|
}
|
|
|
|
// set the div to the correct position
|
|
div.style['position'] = 'absolute';
|
|
div.style['top'] = `${inputY}px`;
|
|
div.style['left'] = `${inputX}px`;
|
|
div.style['opacity'] = 0;
|
|
|
|
// replace whitespace with '.' when filling the dummy element if it's a single line <input/>
|
|
const swap = '.';
|
|
const inputValue =
|
|
input.tagName === 'INPUT' ? input.value.replace(/ /g, swap) : input.value;
|
|
|
|
// set the div content to that of the textarea up until selection point
|
|
div.textContent = inputValue.substr(0, selectionPoint);
|
|
|
|
if (input.tagName === 'TEXTAREA') div.style.height = 'auto';
|
|
// if a single line input then the div needs to be single line and not break out like a text area
|
|
if (input.tagName === 'INPUT') div.style.width = 'auto';
|
|
|
|
// marker element to obtain caret position
|
|
const span = document.createElement('span');
|
|
// give the span the textContent of remaining content so that the recreated dummy element is as close as possible
|
|
span.textContent = inputValue.substr(selectionPoint) || '.';
|
|
|
|
// append the span marker to the div and the dummy element to the body
|
|
div.appendChild(span);
|
|
document.body.appendChild(div);
|
|
|
|
// get the marker position, this is the caret position top and left relative to the input
|
|
const { offsetLeft: spanX, offsetTop: spanY } = span;
|
|
|
|
// remove dummy element
|
|
document.body.removeChild(div);
|
|
|
|
// return object with the x and y of the caret. account for input positioning so that you don't need to wrap the input
|
|
return {
|
|
x: inputX + spanX,
|
|
y: inputY + spanY,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* A helper function that searches back to the beginning of the currently typed word (indicated by cursor position) and verifies whether it begins with an '@' symbol for user mention
|
|
*
|
|
* @param {element} textArea The text area or input to inspect the current word of
|
|
* @returns {{isUserMention: boolean, indexOfMentionStart: number}} Object with the word's mention data
|
|
*
|
|
* @example
|
|
* const { isUserMention, indexOfMentionStart } = getMentionWordData(textArea);
|
|
* if (isUserMention) {
|
|
* // Do something
|
|
* }
|
|
*/
|
|
export const getMentionWordData = (textArea) => {
|
|
const { selectionStart, value: valueBeforeKeystroke } = textArea;
|
|
|
|
if (selectionStart === 0 || valueBeforeKeystroke === '') {
|
|
return {
|
|
isUserMention: false,
|
|
indexOfMentionStart: -1,
|
|
};
|
|
}
|
|
|
|
const indexOfAutocompleteStart = getLastIndexOfCharacter({
|
|
content: valueBeforeKeystroke,
|
|
selectionIndex: selectionStart,
|
|
character: '@',
|
|
breakOnCharacters: [' ', '', '\n'],
|
|
});
|
|
|
|
return {
|
|
isUserMention: indexOfAutocompleteStart !== -1,
|
|
indexOfMentionStart: indexOfAutocompleteStart,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Searches backwards through text content for the last occurrence of the given character
|
|
*
|
|
* @param {Object} params
|
|
* @param {string} content The chunk of text to search within
|
|
* @param {number} selectionIndex The starting point to search from
|
|
* @param {string} character The character to search for
|
|
* @param {string[]} breakOnCharacters Any characters which should result in an immediate halt to the search
|
|
* @returns {number} Index of the last occurrence of the character, or -1 if it isn't found
|
|
*/
|
|
export const getLastIndexOfCharacter = ({
|
|
content,
|
|
selectionIndex,
|
|
character,
|
|
breakOnCharacters = [],
|
|
}) => {
|
|
const currentCharacter = content.charAt(selectionIndex);
|
|
const previousCharacter = content.charAt(selectionIndex - 1);
|
|
|
|
if (currentCharacter === character) {
|
|
return selectionIndex;
|
|
}
|
|
|
|
if (selectionIndex !== 0 && !breakOnCharacters.includes(previousCharacter)) {
|
|
return getLastIndexOfCharacter({
|
|
content,
|
|
selectionIndex: selectionIndex - 1,
|
|
character,
|
|
breakOnCharacters,
|
|
});
|
|
}
|
|
|
|
return -1;
|
|
};
|
|
|
|
/**
|
|
* Searches forwards through text content for the next occurrence of the given character
|
|
*
|
|
* @param {Object} params
|
|
* @param {string} content The chunk of text to search within
|
|
* @param {number} selectionIndex The starting point to search from
|
|
* @param {string} character The character to search for
|
|
* @param {string[]} breakOnCharacters Any characters which should result in an immediate halt to the search
|
|
* @returns {number} Index of the next occurrence of the character, or -1 if it isn't found
|
|
*/
|
|
export const getNextIndexOfCharacter = ({
|
|
content,
|
|
selectionIndex,
|
|
character,
|
|
breakOnCharacters = [],
|
|
}) => {
|
|
const currentCharacter = content.charAt(selectionIndex);
|
|
const nextCharacter = content.charAt(selectionIndex + 1);
|
|
|
|
if (currentCharacter === character) {
|
|
return selectionIndex;
|
|
}
|
|
|
|
if (
|
|
selectionIndex <= content.length &&
|
|
!breakOnCharacters.includes(nextCharacter)
|
|
) {
|
|
return getNextIndexOfCharacter({
|
|
content,
|
|
selectionIndex: selectionIndex + 1,
|
|
character,
|
|
breakOnCharacters,
|
|
});
|
|
}
|
|
|
|
return -1;
|
|
};
|
|
|
|
/**
|
|
* Counts how many new lines come immediately before the user's current selection start
|
|
* @param {object} args
|
|
* @param {number} args.selectionStart The index of user's current selection start
|
|
* @param {string} args.value The value of the textarea
|
|
*
|
|
* @returns {number} Number of new lines directly before selection start
|
|
*/
|
|
export const getNumberOfNewLinesPrecedingSelection = ({
|
|
selectionStart,
|
|
value,
|
|
}) => {
|
|
if (selectionStart === 0) {
|
|
return 0;
|
|
}
|
|
|
|
let count = 0;
|
|
let searchIndex = selectionStart - 1;
|
|
|
|
while (searchIndex >= 0 && value.charAt(searchIndex) === '\n') {
|
|
count++;
|
|
searchIndex--;
|
|
}
|
|
|
|
return count;
|
|
};
|
|
|
|
/**
|
|
* Counts how many new lines come immediately after the user's current selection end
|
|
*
|
|
* @param {object} args
|
|
* @param {number} args.selectionEnd The index of user's current selection end
|
|
* @param {string} args.value The value of the textarea
|
|
*
|
|
* @returns {number} the count of new line characters immediately following selection
|
|
*/
|
|
export const getNumberOfNewLinesFollowingSelection = ({
|
|
selectionEnd,
|
|
value,
|
|
}) => {
|
|
if (selectionEnd === value.length) {
|
|
return 0;
|
|
}
|
|
|
|
let count = 0;
|
|
let searchIndex = selectionEnd;
|
|
|
|
while (searchIndex < value.length && value.charAt(searchIndex) === '\n') {
|
|
count++;
|
|
searchIndex++;
|
|
}
|
|
|
|
return count;
|
|
};
|
|
|
|
/**
|
|
* Retrieve data about the user's current text selection
|
|
*
|
|
* @param {Object} params
|
|
* @param {number} selectionStart The start point of user's selection
|
|
* @param {number} selectionEnd The end point of user's selection
|
|
* @param {string} value The current value of the textarea
|
|
* @returns {Object} object containing the text chunks before and after insertion, and the currently selected text
|
|
*/
|
|
export const getSelectionData = ({ selectionStart, selectionEnd, value }) => ({
|
|
textBeforeSelection: value.substring(0, selectionStart),
|
|
textAfterSelection: value.substring(selectionEnd, value.length),
|
|
selectedText: value.substring(selectionStart, selectionEnd),
|
|
});
|
|
|
|
/**
|
|
* This hook can be used to keep the height of a textarea in step with the current content height, avoiding a scrolling textarea.
|
|
* An optional array of additional elements can be set. If provided, all elements will be set to the greatest content height.
|
|
* Optionally, it can be specified to also constrain the max-height to the content height. Otherwise the max-height will continue to be managed only by the textarea's CSS
|
|
*
|
|
* @example
|
|
*
|
|
* const { setTextArea } = useTextAreaAutoResize();
|
|
* setTextArea(myTextAreaRef.current);
|
|
* setAdditionalElements([myOtherElement.current]);
|
|
*/
|
|
export const useTextAreaAutoResize = () => {
|
|
const [textArea, setTextArea] = useState(null);
|
|
const [constrainToContentHeight, setConstrainToContentHeight] =
|
|
useState(false);
|
|
const [additionalElements, setAdditionalElements] = useState([]);
|
|
|
|
useEffect(() => {
|
|
if (!textArea) {
|
|
return;
|
|
}
|
|
|
|
const resizeTextArea = () => {
|
|
const allElements = [textArea, ...additionalElements];
|
|
|
|
const allContentHeights = allElements.map(
|
|
(element) => calculateTextAreaHeight(element).height,
|
|
);
|
|
|
|
const height = Math.max(...allContentHeights);
|
|
const newHeight = `${height}px`;
|
|
|
|
[textArea, ...additionalElements].forEach((element) => {
|
|
element.style['min-height'] = newHeight;
|
|
if (constrainToContentHeight) {
|
|
// Don't allow the textarea to grow to a size larger than the content
|
|
element.style['max-height'] = newHeight;
|
|
}
|
|
});
|
|
};
|
|
|
|
// Resize on first attach
|
|
resizeTextArea();
|
|
// Resize on subsequent value changes
|
|
textArea.addEventListener('input', resizeTextArea);
|
|
|
|
return () => textArea.removeEventListener('input', resizeTextArea);
|
|
}, [textArea, additionalElements, constrainToContentHeight]);
|
|
|
|
return { setTextArea, setAdditionalElements, setConstrainToContentHeight };
|
|
};
|