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:
parent
dad0f9d4cc
commit
832d0d3a4d
6 changed files with 2294 additions and 403 deletions
|
|
@ -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) =>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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: '',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue