Markdown toolbar: add 'undo' functionality (#15239)

* WIP - main formatters with undo syntax

* tests for new text area utils

* WIP: formatters undoing and formatting, tests to follow

* small tweak

* WIP - tweaks to heading

* add heading level tests

* refactor link formatting into separate functions, flesh out tests

* add missing test case, adjust link undo
This commit is contained in:
Suzanne Aitchison 2021-11-10 12:06:25 +00:00 committed by GitHub
parent dad0f9d4cc
commit 832d0d3a4d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 2294 additions and 403 deletions

View file

@ -8,7 +8,6 @@ import { Overflow, Help } from './icons';
import { Button } from '@crayons';
import { KeyboardShortcuts } from '@components/useKeyboardShortcuts';
import { BREAKPOINTS, useMediaQuery } from '@components/useMediaQuery';
import { getIndexOfLineStart } from '@utilities/textAreaUtils';
export const MarkdownToolbar = ({ textAreaId }) => {
const [textArea, setTextArea] = useState(null);
@ -126,63 +125,15 @@ export const MarkdownToolbar = ({ textAreaId }) => {
}
};
const getSelectionData = (syntaxName) => {
const {
selectionStart: initialSelectionStart,
selectionEnd,
value,
} = textArea;
let selectionStart = initialSelectionStart;
// The 'heading' formatter can edit a previously inserted syntax,
// so we check if we need adjust the selection to the start of the line
if (syntaxName === 'heading') {
const indexOfLineStart = getIndexOfLineStart(
textArea.value,
initialSelectionStart,
);
if (textArea.value.charAt(indexOfLineStart + 1) === '#') {
selectionStart = indexOfLineStart;
}
}
const textBeforeInsertion = value.substring(0, selectionStart);
const textAfterInsertion = value.substring(selectionEnd, value.length);
const selectedText = value.substring(selectionStart, selectionEnd);
return {
textBeforeInsertion,
textAfterInsertion,
selectedText,
selectionStart,
selectionEnd,
};
};
const insertSyntax = (syntaxName) => {
setOverflowMenuOpen(false);
const {
textBeforeInsertion,
textAfterInsertion,
selectedText,
selectionStart,
selectionEnd,
} = getSelectionData(syntaxName);
const { newTextAreaValue, newCursorStart, newCursorEnd } =
markdownSyntaxFormatters[syntaxName].getFormatting(textArea);
const { formattedText, cursorOffsetStart, cursorOffsetEnd } =
markdownSyntaxFormatters[syntaxName].getFormatting(selectedText);
const newTextContent = `${textBeforeInsertion}${formattedText}${textAfterInsertion}`;
textArea.value = newTextContent;
textArea.value = newTextAreaValue;
textArea.focus({ preventScroll: true });
textArea.setSelectionRange(
selectionStart + cursorOffsetStart,
selectionEnd + cursorOffsetEnd,
);
textArea.setSelectionRange(newCursorStart, newCursorEnd);
};
const getSecondaryFormatterButtons = (isOverflow) =>

View file

@ -1,4 +1,9 @@
/* global Runtime */
import {
getLastIndexOfCharacter,
getNextIndexOfCharacter,
getSelectionData,
} from '../../utilities/textAreaUtils';
import {
Bold,
Italic,
@ -14,11 +19,336 @@ import {
Divider,
} from './icons';
const ORDERED_LIST_ITEM_REGEX = /^\d+\.\s+.*/;
const MARKDOWN_LINK_REGEX =
/^\[([\w\s\d]*)\]\((url|(https?:\/\/[\w\d./?=#]+))\)$/;
const URL_PLACEHOLDER_TEXT = 'url';
const handleLinkFormattingForEmptyTextSelection = ({
textBeforeSelection,
textAfterSelection,
value,
selectionStart,
selectionEnd,
}) => {
const basicFormattingForEmptySelection = {
newTextAreaValue: `${textBeforeSelection}[](${URL_PLACEHOLDER_TEXT})${textAfterSelection}`,
newCursorStart: selectionStart + 3,
newCursorEnd: selectionEnd + 6,
};
// Directly after inserting a link with a URL highlighted, cursor is inside the link description '[]'
// Check if we are inside empty link description remove the link syntax if so
const directlySurroundedByLinkStructure =
textBeforeSelection.slice(-1) === '[' &&
textAfterSelection.slice(0, 2) === '](';
if (!directlySurroundedByLinkStructure)
return basicFormattingForEmptySelection;
// Search for the closing bracket of markdown link
const indexOfLinkStructureEnd = getNextIndexOfCharacter({
content: value,
selectionIndex: selectionStart,
character: ')',
breakOnCharacters: [' ', '\n'],
});
if (indexOfLinkStructureEnd === -1) return basicFormattingForEmptySelection;
// Remove the markdown link structure, preserving the link text if it isn't the "url" placeholder
const urlText = value.slice(selectionEnd + 2, indexOfLinkStructureEnd);
return {
newTextAreaValue: `${textBeforeSelection.slice(0, -1)}${
urlText === URL_PLACEHOLDER_TEXT ? '' : urlText
}${value.slice(indexOfLinkStructureEnd + 1)}`,
newCursorStart: selectionStart - 1,
newCursorEnd: selectionEnd - 1,
};
};
const handleLinkFormattingForUrlSelection = ({
textBeforeSelection,
textAfterSelection,
value,
selectionStart,
selectedText,
}) => {
const basicFormattingForLinkSelection = {
newTextAreaValue: `${textBeforeSelection}[](${selectedText})${textAfterSelection}`,
newCursorStart: selectionStart + 1,
newCursorEnd: selectionStart + 1,
};
// Check if the text selection is likely inside a currently formatted markdown link
const directlySurroundedByLinkStructure =
textBeforeSelection.slice(-2) === '](' &&
textAfterSelection.slice(0, 1) === ')';
if (!directlySurroundedByLinkStructure)
return basicFormattingForLinkSelection;
// Get the index of where the current link opens so we can get the text inside the square brackets
const indexOfSyntaxOpen = getLastIndexOfCharacter({
content: value,
selectionIndex: selectionStart,
character: '[',
});
// If link syntax is incomplete, format the selection as a link
if (indexOfSyntaxOpen === -1) return basicFormattingForLinkSelection;
// Replace the markdown with the link text in square brackets, if available
let textToReplaceMarkdown = textBeforeSelection.slice(
indexOfSyntaxOpen + 1,
-2,
);
// If not available, take the URL as long as it's not the placeholder 'url' text
if (textToReplaceMarkdown === '') {
textToReplaceMarkdown =
selectedText === URL_PLACEHOLDER_TEXT ? '' : selectedText;
}
return {
newTextAreaValue: `${textBeforeSelection.slice(
0,
indexOfSyntaxOpen,
)}${textToReplaceMarkdown}${textAfterSelection.slice(1)}`,
newCursorStart: indexOfSyntaxOpen,
newCursorEnd: indexOfSyntaxOpen + textToReplaceMarkdown.length,
};
};
const handleUndoMarkdownLinkSelection = ({
selectedText,
selectionStart,
textBeforeSelection,
textAfterSelection,
}) => {
const linkDescriptionEnd = getNextIndexOfCharacter({
content: selectedText,
selectionIndex: 0,
character: ']',
});
let textToReplaceMarkdown = selectedText.slice(1, linkDescriptionEnd);
// Keep the URL instead if no link description exists
if (textToReplaceMarkdown === '') {
const linkText = selectedText.slice(linkDescriptionEnd + 2, -1);
textToReplaceMarkdown = linkText === URL_PLACEHOLDER_TEXT ? '' : linkText;
}
return {
newTextAreaValue: `${textBeforeSelection}${textToReplaceMarkdown}${textAfterSelection}`,
newCursorStart: selectionStart,
newCursorEnd: selectionStart + textToReplaceMarkdown.length,
};
};
const isStringStartAUrl = (string) => {
const startingText = string.substring(0, 8);
return startingText === 'https://' || startingText.startsWith('http://');
};
const undoOrAddFormattingForInlineSyntax = ({
value,
selectionStart,
selectionEnd,
prefix,
suffix,
}) => {
const { length: prefixLength } = prefix;
const { length: suffixLength } = suffix;
const { selectedText, textBeforeSelection, textAfterSelection } =
getSelectionData({ selectionStart, selectionEnd, value });
// Check if selected text has prefix/suffix
const selectedTextAlreadyFormatted =
selectedText.slice(0, prefixLength) === prefix &&
selectedText.slice(-1 * suffixLength) === suffix;
if (selectedTextAlreadyFormatted) {
return {
newTextAreaValue: `${textBeforeSelection}${selectedText.slice(
prefixLength,
-1 * suffixLength,
)}${textAfterSelection}`,
newCursorStart: selectionStart,
newCursorEnd: selectionEnd - (prefixLength + suffixLength),
};
}
// Check if immediate surrounding content has prefix/suffix
const surroundingTextHasFormatting =
textBeforeSelection.substring(textBeforeSelection.length - prefixLength) ===
prefix && textAfterSelection.substring(0, suffixLength) === suffix;
if (surroundingTextHasFormatting) {
return {
newTextAreaValue: `${textBeforeSelection.slice(
0,
-1 * prefixLength,
)}${selectedText}${textAfterSelection.slice(suffixLength)}`,
newCursorStart: selectionStart - prefixLength,
newCursorEnd: selectionEnd - prefixLength,
};
}
// No formatting to undo - format the selected text
return {
newTextAreaValue: `${textBeforeSelection}${prefix}${selectedText}${suffix}${textAfterSelection}`,
newCursorStart: selectionStart + prefixLength,
newCursorEnd: selectionEnd + prefixLength,
};
};
const undoOrAddFormattingForMultilineSyntax = ({
selectionStart,
selectionEnd,
value,
linePrefix,
blockPrefix,
blockSuffix,
}) => {
const { selectedText, textBeforeSelection, textAfterSelection } =
getSelectionData({ selectionStart, selectionEnd, value });
let formattedText = selectedText;
if (linePrefix) {
const { length: prefixLength } = linePrefix;
// If no selection, check if we're in a freshly inserted syntax
if (selectedText === '' && textBeforeSelection !== '') {
const lastNewLine = getLastIndexOfCharacter({
content: value,
selectionIndex: selectionStart - 1,
character: '\n',
});
if (
lastNewLine !== -1 &&
textBeforeSelection.slice(
lastNewLine + 1,
lastNewLine + prefixLength + 1,
) === linePrefix
) {
// Remove the list formatting
return {
newTextAreaValue: `${value.slice(0, lastNewLine + 1)}${value.slice(
lastNewLine + prefixLength + 1,
)}`,
newCursorStart: selectionStart - prefixLength,
newCursorEnd: selectionEnd - prefixLength,
};
}
}
// Split by new lines and check each line has formatting
const splitByNewLine = selectedText
.split('\n')
.filter((line) => line !== '');
const isAlreadyFormatted =
splitByNewLine.length > 0 &&
splitByNewLine.every(
(line) => line.slice(0, prefixLength) === linePrefix,
);
if (isAlreadyFormatted) {
// Remove the formatting
const unformattedText = splitByNewLine
.map((line) => line.slice(prefixLength))
.join('\n');
return {
newTextAreaValue: `${textBeforeSelection}${unformattedText}${textAfterSelection}`,
newCursorStart: selectionStart,
newCursorEnd:
selectionEnd + (unformattedText.length - selectedText.length),
};
}
// Otherwise add the prefix to each line to create the new formatted text
formattedText =
selectedText === ''
? linePrefix
: splitByNewLine.map((line) => `${linePrefix}${line}`).join('\n');
} else {
// Uses only block prefix and suffix
const { length: prefixLength } = blockPrefix;
const { length: suffixLength } = blockSuffix;
// does the selection start and end with the prefix/suffix
const selectionIsFormatted =
selectedText.slice(0, prefixLength) === blockPrefix &&
selectedText.slice(-1 * suffixLength) === blockSuffix;
if (selectionIsFormatted) {
return {
newTextAreaValue: `${textBeforeSelection}${selectedText.slice(
prefixLength,
-1 * suffixLength,
)}${textAfterSelection}`,
newCursorStart: selectionStart,
newCursorEnd: selectionEnd - prefixLength - suffixLength,
};
}
// or does the prefix/suffix plus new line chars immediately precede and follow the selection
const surroundingTextIsFormatted =
textBeforeSelection.slice(-1 * prefixLength) === blockPrefix &&
textAfterSelection.slice(0, suffixLength) === blockSuffix;
if (surroundingTextIsFormatted) {
return {
newTextAreaValue: `${textBeforeSelection.slice(
0,
-1 * prefixLength,
)}${selectedText}${textAfterSelection.slice(suffixLength)}`,
newCursorStart: selectionStart - prefixLength,
newCursorEnd: selectionEnd - prefixLength,
};
}
}
// Add the formatting
const numberOfNewLinesBeforeSelection = (
textBeforeSelection.slice(-2).match(/\n/g) || []
).length;
// Multiline insertions should occur after two new lines (whether added already by user or inserted automatically)
const newLinesToAddBeforeSelection = 2 - numberOfNewLinesBeforeSelection;
let newtextBeforeSelection = textBeforeSelection;
Array.from({ length: newLinesToAddBeforeSelection }, () => {
newtextBeforeSelection += '\n';
});
const cursorStartBaseline = selectionStart + newLinesToAddBeforeSelection;
const cursorStartBlockPrefixOffset = blockPrefix ? blockPrefix.length : 0;
const cursorStartLinePrefixOffset =
selectedText === '' && linePrefix ? linePrefix.length : 0;
return {
newTextAreaValue: `${newtextBeforeSelection}${
blockPrefix ? blockPrefix : ''
}${formattedText}${blockSuffix ? blockSuffix : ''}\n${textAfterSelection}`,
newCursorStart:
cursorStartBaseline +
cursorStartBlockPrefixOffset +
cursorStartLinePrefixOffset,
newCursorEnd:
selectionEnd +
formattedText.length -
selectedText.length +
newLinesToAddBeforeSelection +
(blockPrefix?.length || 0),
};
};
export const coreSyntaxFormatters = {
bold: {
icon: Bold,
@ -30,11 +360,15 @@ export const coreSyntaxFormatters = {
tooltipHint: `${modifier.toUpperCase()} + B`,
};
},
getFormatting: (selection) => ({
formattedText: `**${selection}**`,
cursorOffsetStart: 2,
cursorOffsetEnd: 2,
}),
getFormatting: ({ selectionStart, selectionEnd, value }) => {
return undoOrAddFormattingForInlineSyntax({
selectionStart,
selectionEnd,
value,
prefix: '**',
suffix: '**',
});
},
},
italic: {
icon: Italic,
@ -46,11 +380,15 @@ export const coreSyntaxFormatters = {
tooltipHint: `${modifier.toUpperCase()} + I`,
};
},
getFormatting: (selection) => ({
formattedText: `_${selection}_`,
cursorOffsetStart: 1,
cursorOffsetEnd: 1,
}),
getFormatting: ({ selectionStart, selectionEnd, value }) => {
return undoOrAddFormattingForInlineSyntax({
selectionStart,
selectionEnd,
value,
prefix: '_',
suffix: '_',
});
},
},
link: {
icon: Link,
@ -62,123 +400,230 @@ export const coreSyntaxFormatters = {
tooltipHint: `${modifier.toUpperCase()} + K`,
};
},
getFormatting: (selection) => {
const isUrl = isStringStartAUrl(selection);
const selectionLength = selection.length;
getFormatting: ({ selectionStart, selectionEnd, value }) => {
const { selectedText, textBeforeSelection, textAfterSelection } =
getSelectionData({ selectionStart, selectionEnd, value });
if (selectionLength === 0) {
return {
formattedText: '[](url)',
cursorOffsetStart: 1,
cursorOffsetEnd: 1,
};
if (selectedText === '') {
return handleLinkFormattingForEmptyTextSelection({
textBeforeSelection,
textAfterSelection,
value,
selectionStart,
selectionEnd,
});
}
if (
isStringStartAUrl(selectedText) ||
selectedText === URL_PLACEHOLDER_TEXT
) {
return handleLinkFormattingForUrlSelection({
textBeforeSelection,
textAfterSelection,
value,
selectionStart,
selectedText,
selectionEnd,
});
}
// If the whole selectedText matches markdown link formatting, undo it
if (selectedText.match(MARKDOWN_LINK_REGEX)) {
return handleUndoMarkdownLinkSelection({
selectedText,
selectionStart,
textBeforeSelection,
textAfterSelection,
});
}
// Finally, handle the case where link syntax is inserted for a selection other than a URL
return {
formattedText: isUrl ? `[](${selection})` : `[${selection}](url)`,
cursorOffsetStart: isUrl ? 1 : selectionLength + 3,
cursorOffsetEnd: isUrl ? -1 * (selectionLength - 1) : 6,
newTextAreaValue: `${textBeforeSelection}[${selectedText}](${URL_PLACEHOLDER_TEXT})${textAfterSelection}`,
newCursorStart: selectionStart + selectedText.length + 3,
newCursorEnd: selectionEnd + 6,
};
},
},
orderedList: {
icon: OrderedList,
label: 'Ordered list',
getFormatting: (selection) => {
let newString = `\n${selection
.split('\n')
.map((textChunk, index) => `${index + 1}. ${textChunk}`)
.join('\n')}`;
getFormatting: ({ selectionStart, selectionEnd, value }) => {
const { selectedText, textBeforeSelection, textAfterSelection } =
getSelectionData({ selectionStart, selectionEnd, value });
if (selection !== '') {
newString += '\n';
if (selectedText === '' && textBeforeSelection !== '') {
// Check start of line for whether we're in an empty ordered list
const lastNewLine = getLastIndexOfCharacter({
content: value,
selectionIndex: selectionStart - 1,
character: '\n',
});
if (
lastNewLine !== -1 &&
textBeforeSelection.slice(lastNewLine + 1, lastNewLine + 4) === '1. '
) {
// Remove the list formatting
return {
newTextAreaValue: `${value.slice(0, lastNewLine + 1)}${value.slice(
lastNewLine + 4,
)}`,
newCursorStart: selectionStart - 3,
newCursorEnd: selectionEnd - 3,
};
}
}
if (selectedText === '') {
// Otherwise insert an empty list for an empty selection
return {
newTextAreaValue: `${textBeforeSelection}\n\n1. \n${textAfterSelection}`,
newCursorStart: selectionStart + 5,
newCursorEnd: selectionEnd + 5,
};
}
const splitByNewLine = selectedText.split('\n');
const isAlreadyAnOrderedList = splitByNewLine.every(
(line) => line.match(ORDERED_LIST_ITEM_REGEX) || line === '',
);
if (isAlreadyAnOrderedList) {
// Undo formatting
const newText = splitByNewLine
.filter((line) => line !== '')
.map((line) => {
const indexOfFullStop = line.indexOf('.');
return line.substring(indexOfFullStop + 2);
})
.join('\n');
return {
newTextAreaValue: `${textBeforeSelection}${newText}${textAfterSelection}`,
newCursorStart: selectionStart + selectedText.indexOf('.') - 1,
newCursorEnd: selectionEnd + newText.length - selectedText.length,
};
}
// Otherwise convert to an ordered list
const formattedList = `\n\n${splitByNewLine
.map((textChunk, index) => `${index + 1}. ${textChunk}`)
.join('\n')}\n`;
return {
formattedText: newString,
cursorOffsetStart: selection.length === 0 ? 4 : 1,
cursorOffsetEnd: newString.length - selection.length,
newTextAreaValue: `${textBeforeSelection}${formattedList}${textAfterSelection}`,
newCursorStart: selectionStart + (selectedText.length === 0 ? 4 : 2),
newCursorEnd:
selectionEnd + formattedList.length - selectedText.length - 1,
};
},
},
unorderedList: {
icon: UnorderedList,
label: 'Unordered list',
getFormatting: (selection) => {
const bulletedSelection = selection.replace(/\n/g, '\n- ');
let newString = `\n- ${bulletedSelection}`;
if (selection !== '') {
newString += '\n';
}
return {
formattedText: newString,
cursorOffsetStart: selection.length === 0 ? 3 : 1,
cursorOffsetEnd: newString.length - selection.length,
};
getFormatting: ({ selectionStart, selectionEnd, value }) => {
return undoOrAddFormattingForMultilineSyntax({
selectionStart,
selectionEnd,
value,
linePrefix: '- ',
});
},
},
heading: {
icon: Heading,
label: 'Heading',
getFormatting: (selection) => {
getFormatting: ({ selectionStart, selectionEnd, value }) => {
let currentLineSelectionStart = selectionStart;
// The 'heading' formatter changes insertion based on the existing heading level of the current line
// So we find the start of the current line and check for '#' characters
if (selectionStart > 0) {
const lastNewLine = getLastIndexOfCharacter({
content: value,
selectionIndex: selectionStart - 1,
character: '\n',
});
const indexOfFirstLineCharacter =
lastNewLine === -1 ? 0 : lastNewLine + 1;
if (value.charAt(indexOfFirstLineCharacter) === '#') {
currentLineSelectionStart = indexOfFirstLineCharacter;
}
}
const { selectedText, textBeforeSelection, textAfterSelection } =
getSelectionData({
selectionStart: currentLineSelectionStart,
selectionEnd,
value,
});
let currentHeadingIndex = 0;
while (selection.charAt(currentHeadingIndex) === '#') {
while (selectedText.charAt(currentHeadingIndex) === '#') {
currentHeadingIndex++;
}
// Only allow up to h4
if (currentHeadingIndex === 4) {
// After h4, revert to no heading at all
if (currentHeadingIndex >= 4) {
return {
formattedText: selection,
cursorOffsetStart: 5,
cursorOffsetEnd: 0,
newTextAreaValue: `${textBeforeSelection}${selectedText.substring(
5,
)}${textAfterSelection}`,
newCursorStart: selectionStart - 5,
newCursorEnd: selectionEnd - 5,
};
}
const adjustingHeading = currentHeadingIndex > 0;
const cursorOffset = adjustingHeading ? 1 : 5;
return {
formattedText: adjustingHeading
? `#${selection}`
: `\n## ${selection}\n`,
cursorOffsetStart: adjustingHeading ? currentHeadingIndex + 2 : 4,
cursorOffsetEnd: adjustingHeading ? 1 : 4,
newTextAreaValue: adjustingHeading
? `${textBeforeSelection}#${selectedText}${textAfterSelection}`
: `${textBeforeSelection}\n\n## ${selectedText}\n${textAfterSelection}`,
newCursorStart: selectionStart + cursorOffset,
newCursorEnd: selectionEnd + cursorOffset,
};
},
},
quote: {
icon: Quote,
label: 'Quote',
getFormatting: (selection) => {
const quotedSelection = selection.replace(/\n/g, '\n> ');
const newString = `\n> ${quotedSelection}\n`;
return {
formattedText: newString,
cursorOffsetStart: 3,
cursorOffsetEnd:
selection === '' ? 3 : newString.length - selection.length,
};
},
getFormatting: ({ selectionStart, selectionEnd, value }) =>
undoOrAddFormattingForMultilineSyntax({
selectionStart,
selectionEnd,
value,
linePrefix: '> ',
}),
},
code: {
icon: Code,
label: 'Code',
getFormatting: (selection) => ({
formattedText: `\`${selection}\``,
cursorOffsetStart: 1,
cursorOffsetEnd: 1,
}),
getFormatting: ({ selectionStart, selectionEnd, value }) =>
undoOrAddFormattingForInlineSyntax({
selectionStart,
selectionEnd,
value,
prefix: '`',
suffix: '`',
}),
},
codeBlock: {
icon: CodeBlock,
label: 'Code block',
getFormatting: (selection) => ({
formattedText: `\n\`\`\`\n${selection}\n\`\`\`\n`,
cursorOffsetStart: 5,
cursorOffsetEnd: 5,
}),
getFormatting: ({ selectionStart, selectionEnd, value }) =>
undoOrAddFormattingForMultilineSyntax({
selectionStart,
selectionEnd,
value,
blockPrefix: '```\n',
blockSuffix: '\n```',
}),
},
};
@ -193,11 +638,14 @@ export const secondarySyntaxFormatters = {
tooltipHint: `${modifier.toUpperCase()} + U`,
};
},
getFormatting: (selection) => ({
formattedText: `<u>${selection}</u>`,
cursorOffsetStart: 3,
cursorOffsetEnd: 3,
}),
getFormatting: ({ selectionStart, selectionEnd, value }) =>
undoOrAddFormattingForInlineSyntax({
selectionStart,
selectionEnd,
value,
prefix: '<u>',
suffix: '</u>',
}),
},
strikethrough: {
icon: Strikethrough,
@ -209,19 +657,25 @@ export const secondarySyntaxFormatters = {
tooltipHint: `${modifier.toUpperCase()} + SHIFT + X`,
};
},
getFormatting: (selection) => ({
formattedText: `~~${selection}~~`,
cursorOffsetStart: 2,
cursorOffsetEnd: 2,
}),
getFormatting: ({ selectionStart, selectionEnd, value }) =>
undoOrAddFormattingForInlineSyntax({
selectionStart,
selectionEnd,
value,
prefix: '~~',
suffix: '~~',
}),
},
divider: {
icon: Divider,
label: 'Line divider',
getFormatting: (selection) => ({
formattedText: `${selection}\n---\n`,
cursorOffsetStart: selection.length + 5,
cursorOffsetEnd: selection.length + 5,
}),
getFormatting: ({ selectionStart, selectionEnd, value }) =>
undoOrAddFormattingForMultilineSyntax({
selectionStart,
selectionEnd,
value,
blockPrefix: '---\n',
blockSuffix: '',
}),
},
};

View file

@ -1,4 +1,9 @@
import { getMentionWordData, getIndexOfLineStart } from '../textAreaUtils';
import {
getMentionWordData,
getLastIndexOfCharacter,
getNextIndexOfCharacter,
getSelectionData,
} from '../textAreaUtils';
describe('getMentionWordData', () => {
it('returns userMention false for cursor at start of input', () => {
@ -62,16 +67,124 @@ describe('getMentionWordData', () => {
});
});
describe('getIndexOfLineStart', () => {
it('returns 0 for empty text', () => {
expect(getIndexOfLineStart('', 0)).toEqual(0);
describe('getLastIndexOfCharacter', () => {
it('returns -1 when content is empty', () => {
expect(
getLastIndexOfCharacter({
content: '',
selectionIndex: 0,
character: 'f',
}),
).toEqual(-1);
});
it('returns start index of 0 for a single line of text', () => {
expect(getIndexOfLineStart('something', 5)).toEqual(0);
it("returns -1 for a character that isn't present", () => {
expect(
getLastIndexOfCharacter({
content: 'abcde',
selectionIndex: 4,
character: 'f',
}),
).toEqual(-1);
});
it('returns start index of line for a multi-line text', () => {
expect(getIndexOfLineStart('one\ntwo', 6)).toEqual(4);
it('returns index of the last occurence within a single word', () => {
expect(
getLastIndexOfCharacter({
content: 'abcde',
selectionIndex: 4,
character: 'b',
}),
).toEqual(1);
});
it('returns index of the last occurence searching through multiple words', () => {
expect(
getLastIndexOfCharacter({
content: 'ab cd ef ghi',
selectionIndex: 10,
character: 'b',
}),
).toEqual(1);
});
it('halts the search when encountering a break character', () => {
expect(
getLastIndexOfCharacter({
content: 'ab cd ef ghi',
selectionIndex: 10,
character: 'b',
breakOnCharacters: [' '],
}),
).toEqual(-1);
});
});
describe('getNextIndexOfCharacter', () => {
it('returns -1 when content is empty', () => {
expect(
getNextIndexOfCharacter({
content: '',
selectionIndex: 0,
character: 'f',
}),
).toEqual(-1);
});
it("returns -1 for a character that isn't present", () => {
expect(
getNextIndexOfCharacter({
content: 'abcde',
selectionIndex: 1,
character: 'f',
}),
).toEqual(-1);
});
it('returns index of the last occurence within a single word', () => {
expect(
getNextIndexOfCharacter({
content: 'abcde',
selectionIndex: 0,
character: 'e',
}),
).toEqual(4);
});
it('returns index of the last occurence searching through multiple words', () => {
expect(
getNextIndexOfCharacter({
content: 'ab cd ef ghi',
selectionIndex: 0,
character: 'f',
}),
).toEqual(7);
});
it('halts the search when encountering a break character', () => {
expect(
getNextIndexOfCharacter({
content: 'ab cd ef ghi',
selectionIndex: 0,
character: 'f',
breakOnCharacters: [' '],
}),
).toEqual(-1);
});
});
describe('getSelectionData', () => {
it('returns selection data for given inputs', () => {
expect(
getSelectionData({
selectionStart: 4,
selectionEnd: 7,
value: 'one two three four',
}),
).toEqual({
textBeforeSelection: 'one ',
textAfterSelection: ' three four',
selectedText: 'two',
});
});
});

View file

@ -89,10 +89,12 @@ export const getMentionWordData = (textArea) => {
};
}
const indexOfAutocompleteStart = getIndexOfCurrentWordAutocompleteSymbol(
valueBeforeKeystroke,
selectionStart,
);
const indexOfAutocompleteStart = getLastIndexOfCharacter({
content: valueBeforeKeystroke,
selectionIndex: selectionStart,
character: '@',
breakOnCharacters: [' ', '', '\n'],
});
return {
isUserMention: indexOfAutocompleteStart !== -1,
@ -100,21 +102,94 @@ export const getMentionWordData = (textArea) => {
};
};
const getIndexOfCurrentWordAutocompleteSymbol = (content, selectionIndex) => {
/**
* Searches backwards through text content for the last occurence 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 occurence 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 (selectionIndex !== 0 && ![' ', '', '\n'].includes(previousCharacter)) {
return getIndexOfCurrentWordAutocompleteSymbol(content, selectionIndex - 1);
if (currentCharacter === character) {
return selectionIndex;
}
if (currentCharacter === '@') {
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 occurence 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 occurence 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;
};
/**
* 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.
@ -166,23 +241,3 @@ export const useTextAreaAutoResize = () => {
return { setTextArea, setAdditionalElements, setConstrainToContentHeight };
};
/**
* Helper function to return the index of the current line's starting point
*
* @param {string} text The text value of the textArea
* @param {number} cursorStart The current position of the user's cursor
* @returns
*/
export const getIndexOfLineStart = (text, cursorStart) => {
const currentCharacter = text.charAt(cursorStart - 1);
if (currentCharacter === '\n') {
return cursorStart;
}
if (cursorStart !== 0) {
return getIndexOfLineStart(text, cursorStart - 1);
}
return 0;
};

View file

@ -83,9 +83,9 @@ describe('Follow from article liquid tag', () => {
it('Follows a user from an article liquid tag', () => {
cy.findByRole('main').within(() => {
cy.findByRole('button', { name: 'Follow user back: Admin McAdmin'}).as(
'followUserButton',
);
cy.findByRole('button', {
name: 'Follow user back: Admin McAdmin',
}).as('followUserButton');
cy.get('@followUserButton').should('have.text', 'Follow back');
cy.get('@followUserButton').click();
cy.get('@followUserButton').should('have.text', 'Following');