From 832d0d3a4d3caf2e2c6c87d98aab69a293fa5fea Mon Sep 17 00:00:00 2001 From: Suzanne Aitchison Date: Wed, 10 Nov 2021 12:06:25 +0000 Subject: [PATCH] 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 --- .../MarkdownToolbar/MarkdownToolbar.jsx | 57 +- .../markdownSyntaxFormatters.test.js | 1754 +++++++++++++++-- .../markdownSyntaxFormatters.js | 638 +++++- .../utilities/__tests__/textAreaUtils.test.js | 129 +- app/javascript/utilities/textAreaUtils.js | 113 +- .../followFromArticleLiquidTag.spec.js | 6 +- 6 files changed, 2294 insertions(+), 403 deletions(-) diff --git a/app/javascript/crayons/MarkdownToolbar/MarkdownToolbar.jsx b/app/javascript/crayons/MarkdownToolbar/MarkdownToolbar.jsx index b1c5509ed..1fcfee9d0 100644 --- a/app/javascript/crayons/MarkdownToolbar/MarkdownToolbar.jsx +++ b/app/javascript/crayons/MarkdownToolbar/MarkdownToolbar.jsx @@ -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) => diff --git a/app/javascript/crayons/MarkdownToolbar/__tests__/markdownSyntaxFormatters.test.js b/app/javascript/crayons/MarkdownToolbar/__tests__/markdownSyntaxFormatters.test.js index b860401d8..7aa67d34d 100644 --- a/app/javascript/crayons/MarkdownToolbar/__tests__/markdownSyntaxFormatters.test.js +++ b/app/javascript/crayons/MarkdownToolbar/__tests__/markdownSyntaxFormatters.test.js @@ -3,256 +3,1574 @@ import { secondarySyntaxFormatters, } from '../markdownSyntaxFormatters'; -describe('markdownSntaxFormatters', () => { - const exampleTextSelection = 'selection'; +describe('markdownSyntaxFormatters', () => { + describe('inline formatters', () => { + describe('bold', () => { + it('Formats selected text as bold, keeping selected text highlighted', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one **two** three'; - it('formats bold text', () => { - expect( - coreSyntaxFormatters['bold'].getFormatting(exampleTextSelection), - ).toEqual({ - formattedText: '**selection**', - cursorOffsetStart: 2, - cursorOffsetEnd: 2, + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['bold'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 7, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('Formats an empty selection as bold, keeping cursor inside formatting', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one ****two three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['bold'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 4, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect(newCursorStart).toEqual(6); + expect(newCursorEnd).toEqual(6); + }); + + it('Unformats a selection that starts and ends with bold formatting', () => { + const textAreaValue = 'one **two** three'; + const expectedNewTextAreaValue = 'one two three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['bold'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 11, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('Formats a selection as bold, when only the start of the selection already has bold formatting', () => { + const textAreaValue = 'one **two three'; + const expectedNewTextAreaValue = 'one ****two** three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['bold'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 9, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('**two'); + }); + + it('Formats a selection as bold, when only the end of the selection already has bold formatting', () => { + const textAreaValue = 'one two** three'; + const expectedNewTextAreaValue = 'one **two**** three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['bold'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 9, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two**'); + }); + + it('Unformats a selection when text immediately before and after have bold formatting', () => { + const textAreaValue = 'one **two** three'; + const expectedNewTextAreaValue = 'one two three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['bold'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 11, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('Formats a selection as bold, when only the text immediately before already has bold formatting', () => { + const textAreaValue = 'one **two three'; + const expectedNewTextAreaValue = 'one ****two** three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['bold'].getFormatting({ + value: textAreaValue, + selectionStart: 6, + selectionEnd: 9, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('Formats a selection as bold, when only the text immediately after already has bold formatting', () => { + const textAreaValue = 'one two** three'; + const expectedNewTextAreaValue = 'one **two**** three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['bold'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 7, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + }); + + describe('italic', () => { + it('Formats selected text as italic, keeping selected text highlighted', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one _two_ three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['italic'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 7, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('Formats an empty selection as italic, keeping cursor inside formatting', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one __two three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['italic'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 4, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect(newCursorStart).toEqual(5); + expect(newCursorEnd).toEqual(5); + }); + + it('Unformats a selection that starts and ends with italic formatting', () => { + const textAreaValue = 'one _two_ three'; + const expectedNewTextAreaValue = 'one two three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['italic'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 9, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('Formats a selection as italic, when only the start of the selection already has italic formatting', () => { + const textAreaValue = 'one _two three'; + const expectedNewTextAreaValue = 'one __two_ three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['italic'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 8, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('_two'); + }); + + it('Formats a selection as italic, when only the end of the selection already has italic formatting', () => { + const textAreaValue = 'one two_ three'; + const expectedNewTextAreaValue = 'one _two__ three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['italic'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 8, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two_'); + }); + + it('Unformats a selection when text immediately before and after have italic formatting', () => { + const textAreaValue = 'one _two_ three'; + const expectedNewTextAreaValue = 'one two three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['italic'].getFormatting({ + value: textAreaValue, + selectionStart: 5, + selectionEnd: 8, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('Formats a selection as italic, when only the text immediately before already has italic formatting', () => { + const textAreaValue = 'one _two three'; + const expectedNewTextAreaValue = 'one __two_ three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['italic'].getFormatting({ + value: textAreaValue, + selectionStart: 5, + selectionEnd: 8, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('Formats a selection as italic, when only the text immediately after already has italic formatting', () => { + const textAreaValue = 'one two_ three'; + const expectedNewTextAreaValue = 'one _two__ three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['italic'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 7, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + }); + + describe('code', () => { + it('Formats selected text as code, keeping selected text highlighted', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one `two` three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['code'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 7, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('Formats an empty selection as code, keeping cursor inside formatting', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one ``two three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['code'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 4, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect(newCursorStart).toEqual(5); + expect(newCursorEnd).toEqual(5); + }); + + it('Unformats a selection that starts and ends with code formatting', () => { + const textAreaValue = 'one `two` three'; + const expectedNewTextAreaValue = 'one two three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['code'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 9, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('Formats a selection as code, when only the start of the selection already has code formatting', () => { + const textAreaValue = 'one `two three'; + const expectedNewTextAreaValue = 'one ``two` three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['code'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 8, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('`two'); + }); + + it('Formats a selection as code, when only the end of the selection already has code formatting', () => { + const textAreaValue = 'one two` three'; + const expectedNewTextAreaValue = 'one `two`` three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['code'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 8, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two`'); + }); + + it('Unformats a selection when text immediately before and after have code formatting', () => { + const textAreaValue = 'one `two` three'; + const expectedNewTextAreaValue = 'one two three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['code'].getFormatting({ + value: textAreaValue, + selectionStart: 5, + selectionEnd: 8, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('Formats a selection as code, when only the text immediately before already has italic formatting', () => { + const textAreaValue = 'one `two three'; + const expectedNewTextAreaValue = 'one ``two` three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['code'].getFormatting({ + value: textAreaValue, + selectionStart: 5, + selectionEnd: 8, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('Formats a selection as code, when only the text immediately after already has code formatting', () => { + const textAreaValue = 'one two` three'; + const expectedNewTextAreaValue = 'one `two`` three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['code'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 7, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + }); + + describe('underline', () => { + it('Formats selected text as underline, keeping selected text highlighted', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one two three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + secondarySyntaxFormatters['underline'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 7, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('Formats an empty selection as underline, keeping cursor inside formatting', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one two three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + secondarySyntaxFormatters['underline'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 4, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect(newCursorStart).toEqual(7); + expect(newCursorEnd).toEqual(7); + }); + + it('Unformats a selection that starts and ends with underline formatting', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one two three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + secondarySyntaxFormatters['underline'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 14, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('Formats a selection as underline, when only the start of the selection already has underline formatting', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one two three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + secondarySyntaxFormatters['underline'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 10, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('Formats a selection as underline, when only the end of the selection already has underline formatting', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one two three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + secondarySyntaxFormatters['underline'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 11, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('Unformats a selection when text immediately before and after have underline formatting', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one two three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + secondarySyntaxFormatters['underline'].getFormatting({ + value: textAreaValue, + selectionStart: 7, + selectionEnd: 10, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('Formats a selection as underline, when only the text immediately before already has underline formatting', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one two three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + secondarySyntaxFormatters['underline'].getFormatting({ + value: textAreaValue, + selectionStart: 7, + selectionEnd: 10, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('Formats a selection as underline, when only the text immediately after already has underline formatting', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one two three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + secondarySyntaxFormatters['underline'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 7, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + }); + + describe('strikethrough', () => { + it('Formats selected text as strikethrough, keeping selected text highlighted', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one ~~two~~ three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + secondarySyntaxFormatters['strikethrough'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 7, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('Formats an empty selection as strikethrough, keeping cursor inside formatting', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one ~~~~two three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + secondarySyntaxFormatters['strikethrough'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 4, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect(newCursorStart).toEqual(6); + expect(newCursorEnd).toEqual(6); + }); + + it('Unformats a selection that starts and ends with strikethrough formatting', () => { + const textAreaValue = 'one ~~two~~ three'; + const expectedNewTextAreaValue = 'one two three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + secondarySyntaxFormatters['strikethrough'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 11, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('Formats a selection as strikethrough, when only the start of the selection already has strikethrough formatting', () => { + const textAreaValue = 'one ~~two three'; + const expectedNewTextAreaValue = 'one ~~~~two~~ three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + secondarySyntaxFormatters['strikethrough'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 9, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('~~two'); + }); + + it('Formats a selection as strikethrough, when only the end of the selection already has strikethrough formatting', () => { + const textAreaValue = 'one two~~ three'; + const expectedNewTextAreaValue = 'one ~~two~~~~ three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + secondarySyntaxFormatters['strikethrough'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 9, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two~~'); + }); + + it('Unformats a selection when text immediately before and after have strikethrough formatting', () => { + const textAreaValue = 'one ~~two~~ three'; + const expectedNewTextAreaValue = 'one two three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + secondarySyntaxFormatters['strikethrough'].getFormatting({ + value: textAreaValue, + selectionStart: 6, + selectionEnd: 9, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('Formats a selection as strikethrough, when only the text immediately before already has strikethrough formatting', () => { + const textAreaValue = 'one ~~two three'; + const expectedNewTextAreaValue = 'one ~~~~two~~ three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + secondarySyntaxFormatters['strikethrough'].getFormatting({ + value: textAreaValue, + selectionStart: 6, + selectionEnd: 9, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('Formats a selection as strikethrough, when only the text immediately after already has strikethrough formatting', () => { + const textAreaValue = 'one two~~ three'; + const expectedNewTextAreaValue = 'one ~~two~~~~ three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + secondarySyntaxFormatters['strikethrough'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 7, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + }); + + describe('link', () => { + it('inserts placeholder link, and highlights url when no selection is given', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one two [](url)three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['link'].getFormatting({ + value: textAreaValue, + selectionStart: 8, + selectionEnd: 8, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('url'); + }); + + it('inserts link markdown, and highlights url when selected text does not begin with http:// or https://', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one [two](url) three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['link'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 7, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('url'); + }); + + it('inserts link markdown, and places cursor inside [], when selected text begins with http://', () => { + const textAreaValue = 'one http://something.com three'; + const expectedNewTextAreaValue = 'one [](http://something.com) three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['link'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 24, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect(newCursorStart).toEqual(5); + expect(newCursorEnd).toEqual(5); + }); + + it('inserts link markdown, and places cursor inside [], when selected text begins with https://', () => { + const textAreaValue = 'one https://something.com three'; + const expectedNewTextAreaValue = 'one [](https://something.com) three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['link'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 25, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect(newCursorStart).toEqual(5); + expect(newCursorEnd).toEqual(5); + }); + + it('removes link markdown when no text selected, cursor inside [], and markdown formatting present', () => { + const textAreaValue = 'one [](url) three'; + const expectedNewTextAreaValue = 'one three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['link'].getFormatting({ + value: textAreaValue, + selectionStart: 5, + selectionEnd: 5, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect(newCursorStart).toEqual(4); + expect(newCursorEnd).toEqual(4); + }); + + it('removes link markdown when placeholder url is selected, and full markdown formatting present, no link description', () => { + const textAreaValue = 'one [](url) three'; + const expectedNewTextAreaValue = 'one three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['link'].getFormatting({ + value: textAreaValue, + selectionStart: 7, + selectionEnd: 10, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect(newCursorStart).toEqual(4); + expect(newCursorEnd).toEqual(4); + }); + + it('removes link markdown when placeholder url is selected, and full markdown formatting present, link description present', () => { + const textAreaValue = 'one [something](url) three'; + const expectedNewTextAreaValue = 'one something three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['link'].getFormatting({ + value: textAreaValue, + selectionStart: 16, + selectionEnd: 19, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('something'); + }); + + it('removes link markdown when selected text is a url and full markdown formatting present, no link description', () => { + const textAreaValue = 'one [](http://example.com) three'; + const expectedNewTextAreaValue = 'one http://example.com three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['link'].getFormatting({ + value: textAreaValue, + selectionStart: 7, + selectionEnd: 25, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('http://example.com'); + }); + + it('removes link markdown when url is selected, and full markdown formatting present, link description present', () => { + const textAreaValue = 'one [something](http://example.com) three'; + const expectedNewTextAreaValue = 'one something three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['link'].getFormatting({ + value: textAreaValue, + selectionStart: 16, + selectionEnd: 34, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('something'); + }); + + it('removes link markdown when full markdown syntax is selected, preserving link description', () => { + const textAreaValue = + 'one [text description](http://example.com) three'; + const expectedNewTextAreaValue = 'one text description three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['link'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 42, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('text description'); + }); + + it('removes link markdown when full markdown syntax is selected, preserving URL if link description does not exist', () => { + const textAreaValue = 'one [](http://example.com) three'; + const expectedNewTextAreaValue = 'one http://example.com three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['link'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 26, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('http://example.com'); + }); + + it('removes link markdown when full markdown syntax is selected, preserving no content if no link description exists, and URL is placeholder', () => { + const textAreaValue = 'one [](url) three'; + const expectedNewTextAreaValue = 'one three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['link'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 11, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect(newCursorStart).toEqual(4); + expect(newCursorEnd).toEqual(4); + }); }); }); - it('formats italic text', () => { - expect( - coreSyntaxFormatters['italic'].getFormatting(exampleTextSelection), - ).toEqual({ - formattedText: '_selection_', - cursorOffsetStart: 1, - cursorOffsetEnd: 1, - }); - }); + describe('multiline formatters', () => { + describe('orderedList', () => { + it('formats a single line selection as an ordered list', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one \n\n1. two\n three'; - it('formats a link with an empty selection', () => { - expect(coreSyntaxFormatters['link'].getFormatting('')).toEqual({ - formattedText: '[](url)', - cursorOffsetStart: 1, - cursorOffsetEnd: 1, - }); - }); + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['orderedList'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 7, + }); - it('formats a link with a non-URL selection', () => { - expect( - coreSyntaxFormatters['link'].getFormatting(exampleTextSelection), - ).toEqual({ - formattedText: '[selection](url)', - cursorOffsetStart: 12, - cursorOffsetEnd: 6, - }); - }); + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('1. two'); + }); - it('formats a link with an http URL selection', () => { - expect( - coreSyntaxFormatters['link'].getFormatting('http://myurl.com'), - ).toEqual({ - formattedText: '[](http://myurl.com)', - cursorOffsetStart: 1, - cursorOffsetEnd: -15, - }); - }); + it('formats multiple lines of text as an ordered list', () => { + const textAreaValue = 'one\ntwo\nthree'; + const expectedNewTextAreaValue = '\n\n1. one\n2. two\n3. three\n'; - it('formats a link with an https URL selection', () => { - expect( - coreSyntaxFormatters['link'].getFormatting('https://myurl.com'), - ).toEqual({ - formattedText: '[](https://myurl.com)', - cursorOffsetStart: 1, - cursorOffsetEnd: -16, - }); - }); + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['orderedList'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 13, + }); - it('formats an unordered list from an empty selection', () => { - expect(coreSyntaxFormatters['unorderedList'].getFormatting('')).toEqual({ - formattedText: '\n- ', - cursorOffsetStart: 3, - cursorOffsetEnd: 3, - }); - }); + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('1. one\n2. two\n3. three'); + }); - it('formats an unordered list from a single line selection', () => { - expect( - coreSyntaxFormatters['unorderedList'].getFormatting(exampleTextSelection), - ).toEqual({ - formattedText: '\n- selection\n', - cursorOffsetStart: 1, - cursorOffsetEnd: 4, - }); - }); + it('inserts an empty list when no selection is provided', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one \n\n1. \ntwo three'; - it('formats an unordered list from a multi-line selection', () => { - expect( - coreSyntaxFormatters['unorderedList'].getFormatting('one\ntwo'), - ).toEqual({ - formattedText: '\n- one\n- two\n', - cursorOffsetStart: 1, - cursorOffsetEnd: 6, - }); - }); + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['orderedList'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 4, + }); - it('formats an ordered list from an empty selection', () => { - expect(coreSyntaxFormatters['orderedList'].getFormatting('')).toEqual({ - formattedText: '\n1. ', - cursorOffsetStart: 4, - cursorOffsetEnd: 4, - }); - }); + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual(''); + }); - it('formats an ordered list from a single line selection', () => { - expect( - coreSyntaxFormatters['orderedList'].getFormatting(exampleTextSelection), - ).toEqual({ - formattedText: '\n1. selection\n', - cursorOffsetStart: 1, - cursorOffsetEnd: 5, - }); - }); + it('unformats a single line of text if selection starts with ordered list format', () => { + const textAreaValue = 'one\n1. two\nthree'; + const expectedNewTextAreaValue = 'one\ntwo\nthree'; - it('formats an ordered list from a multi-line selection', () => { - expect( - coreSyntaxFormatters['orderedList'].getFormatting('one\ntwo'), - ).toEqual({ - formattedText: '\n1. one\n2. two\n', - cursorOffsetStart: 1, - cursorOffsetEnd: 8, - }); - }); + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['orderedList'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 10, + }); - it('Formats a heading from an empty selection', () => { - expect(coreSyntaxFormatters['heading'].getFormatting('')).toEqual({ - formattedText: '\n## \n', - cursorOffsetStart: 4, - cursorOffsetEnd: 4, - }); - }); + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); - it('Formats a heading from a selection with no heading level', () => { - expect( - coreSyntaxFormatters['heading'].getFormatting(exampleTextSelection), - ).toEqual({ - formattedText: '\n## selection\n', - cursorOffsetStart: 4, - cursorOffsetEnd: 4, - }); - }); + it('unformats a single line of text if no selection is given, and current line start contains 1. ', () => { + const textAreaValue = 'one\n\n1. two\nthree'; + const expectedNewTextAreaValue = 'one\n\ntwo\nthree'; - it('Formats a heading from a selection with a heading level 2', () => { - expect( - coreSyntaxFormatters['heading'].getFormatting('## selection'), - ).toEqual({ - formattedText: '### selection', - cursorOffsetStart: 4, - cursorOffsetEnd: 1, - }); - }); + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['orderedList'].getFormatting({ + value: textAreaValue, + selectionStart: 9, + selectionEnd: 9, + }); - it('Formats a heading from a selection with a heading level 3', () => { - expect( - coreSyntaxFormatters['heading'].getFormatting('### selection'), - ).toEqual({ - formattedText: '#### selection', - cursorOffsetStart: 5, - cursorOffsetEnd: 1, - }); - }); + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect(newCursorStart).toEqual(6); + expect(newCursorEnd).toEqual(6); + }); - it('Formats a heading from a selection with a heading level 4 by returning same selection', () => { - expect( - coreSyntaxFormatters['heading'].getFormatting('#### selection'), - ).toEqual({ - formattedText: '#### selection', - cursorOffsetStart: 5, - cursorOffsetEnd: 0, - }); - }); + it('unformats multiple lines of text if every line starts with ordered list format', () => { + const textAreaValue = '1. one\n2. two\n3. three'; + const expectedNewTextAreaValue = 'one\ntwo\nthree'; - it('formats a quote with empty selection', () => { - expect(coreSyntaxFormatters['quote'].getFormatting('')).toEqual({ - formattedText: '\n> \n', - cursorOffsetStart: 3, - cursorOffsetEnd: 3, - }); - }); + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['orderedList'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 22, + }); - it('formats a quote on a single-line selection', () => { - expect( - coreSyntaxFormatters['quote'].getFormatting(exampleTextSelection), - ).toEqual({ - formattedText: '\n> selection\n', - cursorOffsetStart: 3, - cursorOffsetEnd: 4, - }); - }); + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual(expectedNewTextAreaValue); + }); - it('formats a quote on a multi-line selection', () => { - expect(coreSyntaxFormatters['quote'].getFormatting('one\ntwo')).toEqual({ - formattedText: '\n> one\n> two\n', - cursorOffsetStart: 3, - cursorOffsetEnd: 6, - }); - }); + it("formats as an ordered list if at least one line of selection doesn't match ordered list format", () => { + const textAreaValue = '1. one\ntwo\n3. three'; + const expectedNewTextAreaValue = '\n\n1. 1. one\n2. two\n3. 3. three\n'; - it('formats inline code', () => { - expect( - coreSyntaxFormatters['code'].getFormatting(exampleTextSelection), - ).toEqual({ - formattedText: '`selection`', - cursorOffsetStart: 1, - cursorOffsetEnd: 1, - }); - }); + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['orderedList'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 19, + }); - it('formats a code block', () => { - expect( - coreSyntaxFormatters['codeBlock'].getFormatting(exampleTextSelection), - ).toEqual({ - formattedText: '\n```\nselection\n```\n', - cursorOffsetStart: 5, - cursorOffsetEnd: 5, + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('1. 1. one\n2. two\n3. 3. three'); + }); }); - }); - it('formats underline text', () => { - expect( - secondarySyntaxFormatters['underline'].getFormatting( - exampleTextSelection, - ), - ).toEqual({ - formattedText: 'selection', - cursorOffsetStart: 3, - cursorOffsetEnd: 3, + describe('unorderedList', () => { + it('formats a single line selection as an unordered list', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one \n\n- two\n three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['unorderedList'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 7, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('- two'); + }); + + it('formats multiple lines of text as an unordered list', () => { + const textAreaValue = 'one\ntwo\nthree'; + const expectedNewTextAreaValue = '\n\n- one\n- two\n- three\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['unorderedList'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 13, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('- one\n- two\n- three'); + }); + + it('inserts an empty list when no selection is provided', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one \n\n- \ntwo three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['unorderedList'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 4, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual(''); + }); + + it('unformats a single line of text if selection starts with unordered list format', () => { + const textAreaValue = 'one\n- two\nthree'; + const expectedNewTextAreaValue = 'one\ntwo\nthree'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['unorderedList'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 9, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('unformats a single line of text if no selection is given, and current line only contains - ', () => { + const textAreaValue = 'one\n\n- two\nthree'; + const expectedNewTextAreaValue = 'one\n\ntwo\nthree'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['unorderedList'].getFormatting({ + value: textAreaValue, + selectionStart: 9, + selectionEnd: 9, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect(newCursorStart).toEqual(7); + expect(newCursorEnd).toEqual(7); + }); + + it('unformats multiple lines of text if every line starts with unordered list format', () => { + const textAreaValue = '- one\n- two\n- three'; + const expectedNewTextAreaValue = 'one\ntwo\nthree'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['unorderedList'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 20, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual(newTextAreaValue); + }); + + it("formats as an unordered list if at least one line of selection doesn't match unordered list format", () => { + const textAreaValue = '- one\ntwo\n- three'; + const expectedNewTextAreaValue = '\n\n- - one\n- two\n- - three\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['unorderedList'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 17, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('- - one\n- two\n- - three'); + }); }); - }); - it('formats strikethrough text', () => { - expect( - secondarySyntaxFormatters['strikethrough'].getFormatting( - exampleTextSelection, - ), - ).toEqual({ - formattedText: '~~selection~~', - cursorOffsetStart: 2, - cursorOffsetEnd: 2, + describe('heading', () => { + it('inserts a level 2 heading when no selection given, and no current heading on the same line', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one \n\n## \ntwo three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['heading'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 4, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect(newCursorStart).toEqual(9); + expect(newCursorEnd).toEqual(9); + }); + + it('inserts a level 2 heading when text selected and line does not include a heading level', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one \n\n## two\n three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['heading'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 7, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect(newCursorStart).toEqual(9); + expect(newCursorEnd).toEqual(12); + }); + + it('changes a level 2 to a level 3 heading when no selection given, and line begins with ##', () => { + const textAreaValue = 'one\n\n## two\nthree'; + const expectedNewTextAreaValue = 'one\n\n### two\nthree'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['heading'].getFormatting({ + value: textAreaValue, + selectionStart: 11, + selectionEnd: 11, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect(newCursorStart).toEqual(12); + expect(newCursorEnd).toEqual(12); + }); + + it('changes a level 2 to a level 3 heading when text selected, and line begins with ##', () => { + const textAreaValue = 'one\n\n## two\nthree'; + const expectedNewTextAreaValue = 'one\n\n### two\nthree'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['heading'].getFormatting({ + value: textAreaValue, + selectionStart: 8, + selectionEnd: 11, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect(newCursorStart).toEqual(9); + expect(newCursorEnd).toEqual(12); + }); + + it('changes a level 3 to a level 4 heading when no selection given, and line begins with ###', () => { + const textAreaValue = 'one\n\n### two\nthree'; + const expectedNewTextAreaValue = 'one\n\n#### two\nthree'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['heading'].getFormatting({ + value: textAreaValue, + selectionStart: 12, + selectionEnd: 12, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect(newCursorStart).toEqual(13); + expect(newCursorEnd).toEqual(13); + }); + + it('changes a level 3 to a level 4 heading when text selected, and line begins with ###', () => { + const textAreaValue = 'one\n\n### two\nthree'; + const expectedNewTextAreaValue = 'one\n\n#### two\nthree'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['heading'].getFormatting({ + value: textAreaValue, + selectionStart: 9, + selectionEnd: 12, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect(newCursorStart).toEqual(10); + expect(newCursorEnd).toEqual(13); + }); + + it('removes a heading when no text selected and line begins with ####', () => { + const textAreaValue = 'one\n\n#### two\nthree'; + const expectedNewTextAreaValue = 'one\n\ntwo\nthree'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['heading'].getFormatting({ + value: textAreaValue, + selectionStart: 13, + selectionEnd: 13, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect(newCursorStart).toEqual(8); + expect(newCursorEnd).toEqual(8); + }); + + it('removes a heading when text selected and line begins with ####', () => { + const textAreaValue = 'one\n\n#### two\nthree'; + const expectedNewTextAreaValue = 'one\n\ntwo\nthree'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['heading'].getFormatting({ + value: textAreaValue, + selectionStart: 10, + selectionEnd: 13, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect(newCursorStart).toEqual(5); + expect(newCursorEnd).toEqual(8); + }); }); - }); - it('adds a line divider when selection is empty', () => { - expect(secondarySyntaxFormatters['divider'].getFormatting('')).toEqual({ - formattedText: '\n---\n', - cursorOffsetStart: 5, - cursorOffsetEnd: 5, + describe('quote', () => { + it('formats a single line selection as a quote', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one \n\n> two\n three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['quote'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 7, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('> two'); + }); + + it('formats multiple lines of text as a quote', () => { + const textAreaValue = 'one\ntwo\nthree'; + const expectedNewTextAreaValue = '\n\n> one\n> two\n> three\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['quote'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 13, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('> one\n> two\n> three'); + }); + + it('inserts an empty quote when no selection is provided', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one \n\n> \ntwo three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['quote'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 4, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual(''); + }); + + it('unformats a single line of text if selection starts with quote format', () => { + const textAreaValue = 'one\n> two\nthree'; + const expectedNewTextAreaValue = 'one\ntwo\nthree'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['quote'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 9, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('unformats a single line of text if no selection is given, and current line only contains > ', () => { + const textAreaValue = 'one\n\n> two\nthree'; + const expectedNewTextAreaValue = 'one\n\ntwo\nthree'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['quote'].getFormatting({ + value: textAreaValue, + selectionStart: 9, + selectionEnd: 9, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect(newCursorStart).toEqual(7); + expect(newCursorEnd).toEqual(7); + }); + + it('unformats multiple lines of text if every line starts with quote format', () => { + const textAreaValue = '> one\n> two\n> three'; + const expectedNewTextAreaValue = 'one\ntwo\nthree'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['quote'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 20, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual(newTextAreaValue); + }); + + it("formats as a quote if at least one line of selection doesn't match quote format", () => { + const textAreaValue = '> one\ntwo\n> three'; + const expectedNewTextAreaValue = '\n\n> > one\n> two\n> > three\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['quote'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 17, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('> > one\n> two\n> > three'); + }); }); - }); - it('adds a line divider after given selection ', () => { - expect( - secondarySyntaxFormatters['divider'].getFormatting(exampleTextSelection), - ).toEqual({ - formattedText: 'selection\n---\n', - cursorOffsetStart: 14, - cursorOffsetEnd: 14, + describe('codeBlock', () => { + it('formats a single line selection as a code block', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one \n\n```\ntwo\n```\n three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['codeBlock'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 7, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('formats multiple lines of text as a code block', () => { + const textAreaValue = 'one\ntwo\nthree'; + const expectedNewTextAreaValue = '\n\n```\none\ntwo\nthree\n```\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['codeBlock'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 13, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('one\ntwo\nthree'); + }); + + it('inserts an empty code block when no selection is provided', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one \n\n```\n\n```\ntwo three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['codeBlock'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 4, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual(''); + }); + + it('unformats a single line of text if selection is wrapped in a code block', () => { + const textAreaValue = 'one\n\n```\ntwo\n```\nthree'; + const expectedNewTextAreaValue = 'one\n\ntwo\nthree'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['codeBlock'].getFormatting({ + value: textAreaValue, + selectionStart: 9, + selectionEnd: 12, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('unformats multiple lines of text if selection is wrapped in a code block', () => { + const textAreaValue = 'one\n\n```\ntwo\nthree\n```\nfour'; + const expectedNewTextAreaValue = 'one\n\ntwo\nthree\nfour'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['codeBlock'].getFormatting({ + value: textAreaValue, + selectionStart: 9, + selectionEnd: 18, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two\nthree'); + }); + + it('unformats if no selection is given, but cursor is wrapped in a code block', () => { + const textAreaValue = 'one\n\n```\n\n```\ntwo'; + const expectedNewTextAreaValue = 'one\n\n\ntwo'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['codeBlock'].getFormatting({ + value: textAreaValue, + selectionStart: 9, + selectionEnd: 9, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual(''); + }); + + it('unformats if selection starts and ends with code block formatting', () => { + const textAreaValue = 'one\n\n```\ntwo\n```\nthree'; + const expectedNewTextAreaValue = 'one\n\ntwo\nthree'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['codeBlock'].getFormatting({ + value: textAreaValue, + selectionStart: 5, + selectionEnd: 16, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + }); + + describe('divider', () => { + it('inserts a divider if no selection is given', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one \n\n---\n\ntwo three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + secondarySyntaxFormatters['divider'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 4, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual(''); + }); + + it('inserts any selected text after the divider', () => { + const textAreaValue = 'one two three'; + const expectedNewTextAreaValue = 'one \n\n---\ntwo\n three'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + secondarySyntaxFormatters['divider'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 7, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it('removes the divider if no selected text, and cursor directly preceded by line formatting', () => { + const textAreaValue = 'one\n\n---\ntwo'; + const expectedNewTextAreaValue = 'one\n\ntwo'; + const { newTextAreaValue, newCursorStart, newCursorEnd } = + secondarySyntaxFormatters['divider'].getFormatting({ + value: textAreaValue, + selectionStart: 9, + selectionEnd: 9, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual(''); + }); + + it('removes the divider if selected text is directly preceded by line formatting', () => { + const textAreaValue = 'one\n\n---\ntwo three'; + const expectedNewTextAreaValue = 'one\n\ntwo three'; + const { newTextAreaValue, newCursorStart, newCursorEnd } = + secondarySyntaxFormatters['divider'].getFormatting({ + value: textAreaValue, + selectionStart: 9, + selectionEnd: 12, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); }); }); }); diff --git a/app/javascript/crayons/MarkdownToolbar/markdownSyntaxFormatters.js b/app/javascript/crayons/MarkdownToolbar/markdownSyntaxFormatters.js index c2f71a091..40505a85f 100644 --- a/app/javascript/crayons/MarkdownToolbar/markdownSyntaxFormatters.js +++ b/app/javascript/crayons/MarkdownToolbar/markdownSyntaxFormatters.js @@ -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: `${selection}`, - cursorOffsetStart: 3, - cursorOffsetEnd: 3, - }), + getFormatting: ({ selectionStart, selectionEnd, value }) => + undoOrAddFormattingForInlineSyntax({ + selectionStart, + selectionEnd, + value, + prefix: '', + suffix: '', + }), }, 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: '', + }), }, }; diff --git a/app/javascript/utilities/__tests__/textAreaUtils.test.js b/app/javascript/utilities/__tests__/textAreaUtils.test.js index 30b9f5245..665221d65 100644 --- a/app/javascript/utilities/__tests__/textAreaUtils.test.js +++ b/app/javascript/utilities/__tests__/textAreaUtils.test.js @@ -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', + }); }); }); diff --git a/app/javascript/utilities/textAreaUtils.js b/app/javascript/utilities/textAreaUtils.js index 92977b9a4..3e0de2b19 100644 --- a/app/javascript/utilities/textAreaUtils.js +++ b/app/javascript/utilities/textAreaUtils.js @@ -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; -}; diff --git a/cypress/integration/seededFlows/articleFlows/followFromArticleLiquidTag.spec.js b/cypress/integration/seededFlows/articleFlows/followFromArticleLiquidTag.spec.js index dad01df50..da3c32939 100644 --- a/cypress/integration/seededFlows/articleFlows/followFromArticleLiquidTag.spec.js +++ b/cypress/integration/seededFlows/articleFlows/followFromArticleLiquidTag.spec.js @@ -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');