docbrown/app/javascript/crayons/MarkdownToolbar/markdownSyntaxFormatters.js
Suzanne Aitchison 029d572cb3
markdown toolbar: refactor formatting insert to allow for undo queue (#15543)
* Refactor formatting insert to allow for undo queue

* use delete command to ensure correct behaviour when replacing with empty string
2021-12-01 09:18:43 +00:00

770 lines
23 KiB
JavaScript

/* global Runtime */
import {
getLastIndexOfCharacter,
getNextIndexOfCharacter,
getNumberOfNewLinesFollowingSelection,
getNumberOfNewLinesPrecedingSelection,
getSelectionData,
} from '../../utilities/textAreaUtils';
import {
Bold,
Italic,
Link,
OrderedList,
UnorderedList,
Heading,
Quote,
Code,
CodeBlock,
Underline,
Strikethrough,
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 NUMBER_OF_NEW_LINES_BEFORE_BLOCK_SYNTAX = 2;
const NUMBER_OF_NEW_LINES_BEFORE_AFTER_SYNTAX = 1;
const getNewLinePrefixSuffixes = ({ selectionStart, selectionEnd, value }) => {
const numberOfNewLinesBeforeSelection = getNumberOfNewLinesPrecedingSelection(
{ selectionStart, value },
);
const numberOfNewLinesFollowingSelection =
getNumberOfNewLinesFollowingSelection({ selectionEnd, value });
// We only add new lines if we're not at the beginning of the text area
const numberOfNewLinesNeededAtStart =
selectionStart === 0
? 0
: NUMBER_OF_NEW_LINES_BEFORE_BLOCK_SYNTAX -
numberOfNewLinesBeforeSelection;
const newLinesPrefix = String.prototype.padStart(
numberOfNewLinesNeededAtStart,
'\n',
);
const newLinesSuffix =
numberOfNewLinesFollowingSelection >=
NUMBER_OF_NEW_LINES_BEFORE_AFTER_SYNTAX
? ''
: '\n';
return { newLinesPrefix, newLinesSuffix };
};
const handleLinkFormattingForEmptyTextSelection = ({
textBeforeSelection,
textAfterSelection,
value,
selectionStart,
selectionEnd,
}) => {
const basicFormattingForEmptySelection = {
editSelectionStart: selectionStart,
editSelectionEnd: selectionEnd,
replaceSelectionWith: `[](${URL_PLACEHOLDER_TEXT})`,
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 {
editSelectionStart: selectionStart - 1,
editSelectionEnd: indexOfLinkStructureEnd + 1,
replaceSelectionWith: urlText === URL_PLACEHOLDER_TEXT ? '' : urlText,
newCursorStart: selectionStart - 1,
newCursorEnd: selectionEnd - 1,
};
};
const handleLinkFormattingForUrlSelection = ({
textBeforeSelection,
textAfterSelection,
value,
selectionStart,
selectionEnd,
selectedText,
}) => {
const basicFormattingForLinkSelection = {
editSelectionStart: selectionStart,
editSelectionEnd: selectionEnd,
replaceSelectionWith: `[](${selectedText})`,
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 {
editSelectionStart: indexOfSyntaxOpen,
editSelectionEnd: selectionEnd + 1,
replaceSelectionWith: textToReplaceMarkdown,
newCursorStart: indexOfSyntaxOpen,
newCursorEnd: indexOfSyntaxOpen + textToReplaceMarkdown.length,
};
};
const handleUndoMarkdownLinkSelection = ({
selectedText,
selectionStart,
selectionEnd,
}) => {
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 {
editSelectionStart: selectionStart,
editSelectionEnd: selectionEnd,
replaceSelectionWith: textToReplaceMarkdown,
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 {
editSelectionStart: selectionStart,
editSelectionEnd: selectionEnd,
replaceSelectionWith: selectedText.slice(prefixLength, -1 * suffixLength),
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 {
editSelectionStart: selectionStart - prefixLength,
editSelectionEnd: selectionEnd + suffixLength,
replaceSelectionWith: selectedText,
newCursorStart: selectionStart - prefixLength,
newCursorEnd: selectionEnd - prefixLength,
};
}
// No formatting to undo - format the selected text
return {
editSelectionStart: selectionStart,
editSelectionEnd: selectionEnd,
replaceSelectionWith: `${prefix}${selectedText}${suffix}`,
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 === '') {
const lastNewLine =
textBeforeSelection === ''
? -1
: getLastIndexOfCharacter({
content: value,
selectionIndex: selectionStart - 1,
character: '\n',
});
const lineStart = lastNewLine === -1 ? 0 : lastNewLine + 1;
if (
textBeforeSelection.slice(lineStart, lineStart + prefixLength) ===
linePrefix
) {
// Remove the list formatting
return {
editSelectionStart: lineStart,
editSelectionEnd: lineStart + prefixLength,
replaceSelectionWith: '',
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 {
editSelectionStart: selectionStart,
editSelectionEnd: selectionEnd,
replaceSelectionWith: unformattedText,
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 {
editSelectionStart: selectionStart,
editSelectionEnd: selectionEnd,
replaceSelectionWith: selectedText.slice(
prefixLength,
-1 * suffixLength,
),
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 {
editSelectionStart: selectionStart - prefixLength,
editSelectionEnd: selectionEnd + suffixLength,
replaceSelectionWith: selectedText,
newCursorStart: selectionStart - prefixLength,
newCursorEnd: selectionEnd - prefixLength,
};
}
}
// Add the formatting
const { newLinesPrefix, newLinesSuffix } = getNewLinePrefixSuffixes({
selectionStart,
selectionEnd,
value,
});
const { length: newLinePrefixLength } = newLinesPrefix;
const cursorStartBaseline = selectionStart + newLinePrefixLength;
const cursorStartBlockPrefixOffset = blockPrefix ? blockPrefix.length : 0;
const cursorStartLinePrefixOffset =
selectedText === '' && linePrefix ? linePrefix.length : 0;
return {
editSelectionStart: selectionStart,
editSelectionEnd: selectionEnd,
replaceSelectionWith: `${newLinesPrefix}${
blockPrefix ? blockPrefix : ''
}${formattedText}${blockSuffix ? blockSuffix : ''}${newLinesSuffix}`,
newCursorStart:
cursorStartBaseline +
cursorStartBlockPrefixOffset +
cursorStartLinePrefixOffset,
newCursorEnd:
selectionEnd +
formattedText.length -
selectedText.length +
newLinePrefixLength +
(blockPrefix?.length || 0),
};
};
export const getNewTextAreaValueWithEdits = ({
textAreaValue,
editSelectionStart,
editSelectionEnd,
replaceSelectionWith,
}) =>
`${textAreaValue.substring(
0,
editSelectionStart,
)}${replaceSelectionWith}${textAreaValue.substring(editSelectionEnd)}`;
export const coreSyntaxFormatters = {
bold: {
icon: Bold,
label: 'Bold',
getKeyboardShortcut: () => {
const modifier = Runtime.getOSKeyboardModifierKeyString();
return {
command: `${modifier}+b`,
tooltipHint: `${modifier.toUpperCase()} + B`,
};
},
getFormatting: ({ selectionStart, selectionEnd, value }) => {
return undoOrAddFormattingForInlineSyntax({
selectionStart,
selectionEnd,
value,
prefix: '**',
suffix: '**',
});
},
},
italic: {
icon: Italic,
label: 'Italic',
getKeyboardShortcut: () => {
const modifier = Runtime.getOSKeyboardModifierKeyString();
return {
command: `${modifier}+i`,
tooltipHint: `${modifier.toUpperCase()} + I`,
};
},
getFormatting: ({ selectionStart, selectionEnd, value }) => {
return undoOrAddFormattingForInlineSyntax({
selectionStart,
selectionEnd,
value,
prefix: '_',
suffix: '_',
});
},
},
link: {
icon: Link,
label: 'Link',
getKeyboardShortcut: () => {
const modifier = Runtime.getOSKeyboardModifierKeyString();
return {
command: `${modifier}+k`,
tooltipHint: `${modifier.toUpperCase()} + K`,
};
},
getFormatting: ({ selectionStart, selectionEnd, value }) => {
const { selectedText, textBeforeSelection, textAfterSelection } =
getSelectionData({ selectionStart, selectionEnd, value });
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,
selectionEnd,
textBeforeSelection,
textAfterSelection,
});
}
// Finally, handle the case where link syntax is inserted for a selection other than a URL
return {
editSelectionStart: selectionStart,
editSelectionEnd: selectionEnd,
replaceSelectionWith: `[${selectedText}](${URL_PLACEHOLDER_TEXT})`,
newCursorStart: selectionStart + selectedText.length + 3,
newCursorEnd: selectionEnd + 6,
};
},
},
orderedList: {
icon: OrderedList,
label: 'Ordered list',
getFormatting: ({ selectionStart, selectionEnd, value }) => {
const { selectedText, textBeforeSelection } = getSelectionData({
selectionStart,
selectionEnd,
value,
});
const { newLinesPrefix, newLinesSuffix } = getNewLinePrefixSuffixes({
selectionStart,
selectionEnd,
value,
});
const { length: newLinePrefixLength } = newLinesPrefix;
const { length: newLineSuffixLength } = newLinesSuffix;
if (selectedText === '') {
// Check start of line for whether we're in an empty ordered list
const lastNewLine =
textBeforeSelection === ''
? -1
: getLastIndexOfCharacter({
content: value,
selectionIndex: selectionStart - 1,
character: '\n',
});
const lineStart = lastNewLine === -1 ? 0 : lastNewLine + 1;
if (textBeforeSelection.slice(lineStart, lineStart + 3) === '1. ') {
// Remove the list formatting
return {
editSelectionStart: lineStart,
editSelectionEnd: lineStart + 3,
replaceSelectionWith: '',
newCursorStart: selectionStart - 3,
newCursorEnd: selectionEnd - 3,
};
}
}
if (selectedText === '') {
// Otherwise insert an empty list for an empty selection
return {
editSelectionStart: selectionStart,
editSelectionEnd: selectionEnd,
replaceSelectionWith: `${newLinesPrefix}1. ${newLinesSuffix}`,
newCursorStart: selectionStart + 3 + newLinePrefixLength,
newCursorEnd: selectionEnd + 3 + newLinePrefixLength,
};
}
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 {
editSelectionStart: selectionStart,
editSelectionEnd: selectionEnd,
replaceSelectionWith: newText,
newCursorStart: selectionStart + selectedText.indexOf('.') - 1,
newCursorEnd: selectionEnd + newText.length - selectedText.length,
};
}
// Otherwise convert to an ordered list
const formattedList = `${newLinesPrefix}${splitByNewLine
.map((textChunk, index) => `${index + 1}. ${textChunk}`)
.join('\n')}${newLinesSuffix}`;
const cursorOffsetStart =
selectedText.length === 0 ? 4 : newLinePrefixLength;
return {
editSelectionStart: selectionStart,
editSelectionEnd: selectionEnd,
replaceSelectionWith: formattedList,
newCursorStart: selectionStart + cursorOffsetStart,
newCursorEnd:
selectionStart + formattedList.length - newLineSuffixLength,
};
},
},
unorderedList: {
icon: UnorderedList,
label: 'Unordered list',
getFormatting: ({ selectionStart, selectionEnd, value }) => {
return undoOrAddFormattingForMultilineSyntax({
selectionStart,
selectionEnd,
value,
linePrefix: '- ',
});
},
},
heading: {
icon: Heading,
label: 'Heading',
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 } = getSelectionData({
selectionStart: currentLineSelectionStart,
selectionEnd,
value,
});
let currentHeadingIndex = 0;
while (selectedText.charAt(currentHeadingIndex) === '#') {
currentHeadingIndex++;
}
// After h4, revert to no heading at all
if (currentHeadingIndex >= 4) {
return {
editSelectionStart: currentLineSelectionStart,
editSelectionEnd: selectionEnd,
replaceSelectionWith: selectedText.substring(5),
newCursorStart: selectionStart - 5,
newCursorEnd: selectionEnd - 5,
};
}
const { newLinesPrefix, newLinesSuffix } = getNewLinePrefixSuffixes({
selectionStart,
selectionEnd,
value,
});
const { length: newLinePrefixLength } = newLinesPrefix;
const adjustingHeading = currentHeadingIndex > 0;
const cursorOffset = adjustingHeading ? 1 : 3 + newLinePrefixLength;
return {
editSelectionStart: adjustingHeading
? currentLineSelectionStart
: selectionStart,
editSelectionEnd: selectionEnd,
replaceSelectionWith: adjustingHeading
? `#${selectedText}`
: `${newLinesPrefix}## ${selectedText}${newLinesSuffix}`,
newCursorStart: selectionStart + cursorOffset,
newCursorEnd: selectionEnd + cursorOffset,
};
},
},
quote: {
icon: Quote,
label: 'Quote',
getFormatting: ({ selectionStart, selectionEnd, value }) =>
undoOrAddFormattingForMultilineSyntax({
selectionStart,
selectionEnd,
value,
linePrefix: '> ',
}),
},
code: {
icon: Code,
label: 'Code',
getFormatting: ({ selectionStart, selectionEnd, value }) =>
undoOrAddFormattingForInlineSyntax({
selectionStart,
selectionEnd,
value,
prefix: '`',
suffix: '`',
}),
},
codeBlock: {
icon: CodeBlock,
label: 'Code block',
getFormatting: ({ selectionStart, selectionEnd, value }) =>
undoOrAddFormattingForMultilineSyntax({
selectionStart,
selectionEnd,
value,
blockPrefix: '```\n',
blockSuffix: '\n```',
}),
},
};
export const secondarySyntaxFormatters = {
underline: {
icon: Underline,
label: 'Underline',
getKeyboardShortcut: () => {
const modifier = Runtime.getOSKeyboardModifierKeyString();
return {
command: `${modifier}+u`,
tooltipHint: `${modifier.toUpperCase()} + U`,
};
},
getFormatting: ({ selectionStart, selectionEnd, value }) =>
undoOrAddFormattingForInlineSyntax({
selectionStart,
selectionEnd,
value,
prefix: '<u>',
suffix: '</u>',
}),
},
strikethrough: {
icon: Strikethrough,
label: 'Strikethrough',
getKeyboardShortcut: () => {
const modifier = Runtime.getOSKeyboardModifierKeyString();
return {
command: `${modifier}+shift+x`,
tooltipHint: `${modifier.toUpperCase()} + SHIFT + X`,
};
},
getFormatting: ({ selectionStart, selectionEnd, value }) =>
undoOrAddFormattingForInlineSyntax({
selectionStart,
selectionEnd,
value,
prefix: '~~',
suffix: '~~',
}),
},
divider: {
icon: Divider,
label: 'Line divider',
getFormatting: ({ selectionStart, selectionEnd, value }) =>
undoOrAddFormattingForMultilineSyntax({
selectionStart,
selectionEnd,
value,
blockPrefix: '---\n',
blockSuffix: '',
}),
},
};