From 460d2f433a8e7e4edae3e56ec5f10709e64f432f Mon Sep 17 00:00:00 2001 From: Suzanne Aitchison Date: Fri, 19 Nov 2021 06:59:21 +0000 Subject: [PATCH] Markdown toolbar - update new line logic for block syntaxes (#15343) * update new line logic for block syntaxes * use padStart --- .../markdownSyntaxFormatters.test.js | 507 +++++++++++++++++- .../markdownSyntaxFormatters.js | 93 +++- .../utilities/__tests__/textAreaUtils.test.js | 96 ++++ app/javascript/utilities/textAreaUtils.js | 55 ++ 4 files changed, 724 insertions(+), 27 deletions(-) diff --git a/app/javascript/crayons/MarkdownToolbar/__tests__/markdownSyntaxFormatters.test.js b/app/javascript/crayons/MarkdownToolbar/__tests__/markdownSyntaxFormatters.test.js index 7aa67d34d..bf613d098 100644 --- a/app/javascript/crayons/MarkdownToolbar/__tests__/markdownSyntaxFormatters.test.js +++ b/app/javascript/crayons/MarkdownToolbar/__tests__/markdownSyntaxFormatters.test.js @@ -914,7 +914,7 @@ describe('markdownSyntaxFormatters', () => { 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'; + const expectedNewTextAreaValue = '1. one\n2. two\n3. three\n'; const { newTextAreaValue, newCursorStart, newCursorEnd } = coreSyntaxFormatters['orderedList'].getFormatting({ @@ -998,7 +998,7 @@ describe('markdownSyntaxFormatters', () => { 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'; + const expectedNewTextAreaValue = '1. 1. one\n2. two\n3. 3. three\n'; const { newTextAreaValue, newCursorStart, newCursorEnd } = coreSyntaxFormatters['orderedList'].getFormatting({ @@ -1012,6 +1012,91 @@ describe('markdownSyntaxFormatters', () => { newTextAreaValue.substring(newCursorStart, newCursorEnd), ).toEqual('1. 1. one\n2. two\n3. 3. three'); }); + + it("doesn't add new lines before list, if at the beginning of text area", () => { + const textAreaValue = 'one'; + const expectedNewTextAreaValue = '1. one\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['orderedList'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 3, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('1. one'); + }); + + it('adds one new line before list, if directly preceded by a single new line', () => { + const textAreaValue = '\none'; + const expectedNewTextAreaValue = '\n\n1. one\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['orderedList'].getFormatting({ + value: textAreaValue, + selectionStart: 1, + selectionEnd: 4, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('1. one'); + }); + + it('adds two new lines before list, if no new lines already exist before it', () => { + const textAreaValue = 'one two'; + const expectedNewTextAreaValue = 'one \n\n1. two\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['orderedList'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 7, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('1. two'); + }); + + it("doesn't add a new line after list if one already exists", () => { + const textAreaValue = 'one\n'; + const expectedNewTextAreaValue = '1. one\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['orderedList'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 3, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('1. one'); + }); + + it('adds a new line after list if none exists', () => { + const textAreaValue = 'one'; + const expectedNewTextAreaValue = '1. one\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['orderedList'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 3, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('1. one'); + }); }); describe('unorderedList', () => { @@ -1034,7 +1119,7 @@ describe('markdownSyntaxFormatters', () => { 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 expectedNewTextAreaValue = '- one\n- two\n- three\n'; const { newTextAreaValue, newCursorStart, newCursorEnd } = coreSyntaxFormatters['unorderedList'].getFormatting({ @@ -1118,7 +1203,7 @@ describe('markdownSyntaxFormatters', () => { 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 expectedNewTextAreaValue = '- - one\n- two\n- - three\n'; const { newTextAreaValue, newCursorStart, newCursorEnd } = coreSyntaxFormatters['unorderedList'].getFormatting({ @@ -1132,6 +1217,91 @@ describe('markdownSyntaxFormatters', () => { newTextAreaValue.substring(newCursorStart, newCursorEnd), ).toEqual('- - one\n- two\n- - three'); }); + + it("doesn't add new lines before list, if at the beginning of text area", () => { + const textAreaValue = 'one'; + const expectedNewTextAreaValue = '- one\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['unorderedList'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 3, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('- one'); + }); + + it('adds one new line before list, if directly preceded by a single new line', () => { + const textAreaValue = '\none'; + const expectedNewTextAreaValue = '\n\n- one\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['unorderedList'].getFormatting({ + value: textAreaValue, + selectionStart: 1, + selectionEnd: 4, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('- one'); + }); + + it('adds two new lines before list, if no new lines already exist before it', () => { + const textAreaValue = 'one two'; + const expectedNewTextAreaValue = 'one \n\n- two\n'; + + 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("doesn't add a new line after list if one already exists", () => { + const textAreaValue = 'one\n'; + const expectedNewTextAreaValue = '- one\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['unorderedList'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 3, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('- one'); + }); + + it('adds a new line after list if none exists', () => { + const textAreaValue = 'one'; + const expectedNewTextAreaValue = '- one\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['unorderedList'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 3, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('- one'); + }); }); describe('heading', () => { @@ -1262,6 +1432,91 @@ describe('markdownSyntaxFormatters', () => { expect(newCursorStart).toEqual(5); expect(newCursorEnd).toEqual(8); }); + + it("doesn't add new lines before heading, if at the beginning of text area", () => { + const textAreaValue = 'one'; + const expectedNewTextAreaValue = '## one\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['heading'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 3, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('one'); + }); + + it('adds one new line before heading, if directly preceded by a single new line', () => { + const textAreaValue = '\none'; + const expectedNewTextAreaValue = '\n\n## one\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['heading'].getFormatting({ + value: textAreaValue, + selectionStart: 1, + selectionEnd: 4, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('one'); + }); + + it('adds two new lines before heading, if no new lines already exist before it', () => { + const textAreaValue = 'one two'; + const expectedNewTextAreaValue = 'one \n\n## two\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['heading'].getFormatting({ + value: textAreaValue, + selectionStart: 4, + selectionEnd: 7, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('two'); + }); + + it("doesn't add a new line after heading if one already exists", () => { + const textAreaValue = 'one\n'; + const expectedNewTextAreaValue = '## one\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['heading'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 3, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('one'); + }); + + it('adds a new line after heading if none exists', () => { + const textAreaValue = 'one'; + const expectedNewTextAreaValue = '## one\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['heading'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 3, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('one'); + }); }); describe('quote', () => { @@ -1284,7 +1539,7 @@ describe('markdownSyntaxFormatters', () => { 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 expectedNewTextAreaValue = '> one\n> two\n> three\n'; const { newTextAreaValue, newCursorStart, newCursorEnd } = coreSyntaxFormatters['quote'].getFormatting({ @@ -1368,7 +1623,7 @@ describe('markdownSyntaxFormatters', () => { 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 expectedNewTextAreaValue = '> > one\n> two\n> > three\n'; const { newTextAreaValue, newCursorStart, newCursorEnd } = coreSyntaxFormatters['quote'].getFormatting({ @@ -1382,6 +1637,91 @@ describe('markdownSyntaxFormatters', () => { newTextAreaValue.substring(newCursorStart, newCursorEnd), ).toEqual('> > one\n> two\n> > three'); }); + + it("doesn't add new lines before quote, if at the beginning of text area", () => { + const textAreaValue = 'one'; + const expectedNewTextAreaValue = '> one\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['quote'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 3, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('> one'); + }); + + it('adds one new line before quote, if directly preceded by a single new line', () => { + const textAreaValue = '\none'; + const expectedNewTextAreaValue = '\n\n> one\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['quote'].getFormatting({ + value: textAreaValue, + selectionStart: 1, + selectionEnd: 4, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('> one'); + }); + + it('adds two new lines before quote, if no new lines already exist before it', () => { + const textAreaValue = 'one two'; + const expectedNewTextAreaValue = 'one \n\n> two\n'; + + 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("doesn't add a new line after quote if one already exists", () => { + const textAreaValue = 'one\n'; + const expectedNewTextAreaValue = '> one\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['quote'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 3, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('> one'); + }); + + it('adds a new line after quote if none exists', () => { + const textAreaValue = 'one'; + const expectedNewTextAreaValue = '> one\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['quote'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 3, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('> one'); + }); }); describe('codeBlock', () => { @@ -1404,7 +1744,7 @@ describe('markdownSyntaxFormatters', () => { 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 expectedNewTextAreaValue = '```\none\ntwo\nthree\n```\n'; const { newTextAreaValue, newCursorStart, newCursorEnd } = coreSyntaxFormatters['codeBlock'].getFormatting({ @@ -1503,6 +1843,91 @@ describe('markdownSyntaxFormatters', () => { newTextAreaValue.substring(newCursorStart, newCursorEnd), ).toEqual('two'); }); + + it("doesn't add new lines before code block, if at the beginning of text area", () => { + const textAreaValue = 'one'; + const expectedNewTextAreaValue = '```\none\n```\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['codeBlock'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 3, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('one'); + }); + + it('adds one new line before code block, if directly preceded by a single new line', () => { + const textAreaValue = '\none'; + const expectedNewTextAreaValue = '\n\n```\none\n```\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['codeBlock'].getFormatting({ + value: textAreaValue, + selectionStart: 1, + selectionEnd: 4, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('one'); + }); + + it('adds two new lines before code block, if no new lines already exist before it', () => { + const textAreaValue = 'one two'; + const expectedNewTextAreaValue = 'one \n\n```\ntwo\n```\n'; + + 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("doesn't add a new line after code block if one already exists", () => { + const textAreaValue = 'one\n'; + const expectedNewTextAreaValue = '```\none\n```\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['codeBlock'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 3, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('one'); + }); + + it('adds a new line after code block if none exists', () => { + const textAreaValue = 'one'; + const expectedNewTextAreaValue = '```\none\n```\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + coreSyntaxFormatters['codeBlock'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 3, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('one'); + }); }); describe('divider', () => { @@ -1571,6 +1996,74 @@ describe('markdownSyntaxFormatters', () => { newTextAreaValue.substring(newCursorStart, newCursorEnd), ).toEqual('two'); }); + + it("doesn't add new lines before divider, if at the beginning of text area", () => { + const textAreaValue = 'one'; + const expectedNewTextAreaValue = '---\none\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + secondarySyntaxFormatters['divider'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 3, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('one'); + }); + + it('adds one new line before divider, if directly preceded by a single new line', () => { + const textAreaValue = '\none'; + const expectedNewTextAreaValue = '\n\n---\none\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + secondarySyntaxFormatters['divider'].getFormatting({ + value: textAreaValue, + selectionStart: 1, + selectionEnd: 4, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('one'); + }); + + it('adds two new lines before divider, if no new lines already exist before it', () => { + const textAreaValue = 'one two'; + const expectedNewTextAreaValue = 'one \n\n---\ntwo\n'; + + 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("doesn't add a new line after divider if one already exists", () => { + const textAreaValue = 'one\n'; + const expectedNewTextAreaValue = '---\none\n'; + + const { newTextAreaValue, newCursorStart, newCursorEnd } = + secondarySyntaxFormatters['divider'].getFormatting({ + value: textAreaValue, + selectionStart: 0, + selectionEnd: 3, + }); + + expect(newTextAreaValue).toEqual(expectedNewTextAreaValue); + expect( + newTextAreaValue.substring(newCursorStart, newCursorEnd), + ).toEqual('one'); + }); }); }); }); diff --git a/app/javascript/crayons/MarkdownToolbar/markdownSyntaxFormatters.js b/app/javascript/crayons/MarkdownToolbar/markdownSyntaxFormatters.js index 40505a85f..f05639c3f 100644 --- a/app/javascript/crayons/MarkdownToolbar/markdownSyntaxFormatters.js +++ b/app/javascript/crayons/MarkdownToolbar/markdownSyntaxFormatters.js @@ -2,6 +2,8 @@ import { getLastIndexOfCharacter, getNextIndexOfCharacter, + getNumberOfNewLinesFollowingSelection, + getNumberOfNewLinesPrecedingSelection, getSelectionData, } from '../../utilities/textAreaUtils'; import { @@ -24,6 +26,37 @@ 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, @@ -316,18 +349,18 @@ const undoOrAddFormattingForMultilineSyntax = ({ } // Add the formatting - const numberOfNewLinesBeforeSelection = ( - textBeforeSelection.slice(-2).match(/\n/g) || [] - ).length; + + const { newLinesPrefix, newLinesSuffix } = getNewLinePrefixSuffixes({ + selectionStart, + selectionEnd, + value, + }); + const { length: newLinePrefixLength } = newLinesPrefix; // 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 newtextBeforeSelection = `${textBeforeSelection}${newLinesPrefix}`; - const cursorStartBaseline = selectionStart + newLinesToAddBeforeSelection; + const cursorStartBaseline = selectionStart + newLinePrefixLength; const cursorStartBlockPrefixOffset = blockPrefix ? blockPrefix.length : 0; const cursorStartLinePrefixOffset = selectedText === '' && linePrefix ? linePrefix.length : 0; @@ -335,7 +368,9 @@ const undoOrAddFormattingForMultilineSyntax = ({ return { newTextAreaValue: `${newtextBeforeSelection}${ blockPrefix ? blockPrefix : '' - }${formattedText}${blockSuffix ? blockSuffix : ''}\n${textAfterSelection}`, + }${formattedText}${ + blockSuffix ? blockSuffix : '' + }${newLinesSuffix}${textAfterSelection}`, newCursorStart: cursorStartBaseline + cursorStartBlockPrefixOffset + @@ -344,7 +379,7 @@ const undoOrAddFormattingForMultilineSyntax = ({ selectionEnd + formattedText.length - selectedText.length + - newLinesToAddBeforeSelection + + newLinePrefixLength + (blockPrefix?.length || 0), }; }; @@ -453,6 +488,14 @@ export const coreSyntaxFormatters = { const { selectedText, textBeforeSelection, textAfterSelection } = getSelectionData({ selectionStart, selectionEnd, value }); + const { newLinesPrefix, newLinesSuffix } = getNewLinePrefixSuffixes({ + selectionStart, + selectionEnd, + value, + }); + const { length: newLinePrefixLength } = newLinesPrefix; + const { length: newLineSuffixLength } = newLinesSuffix; + if (selectedText === '' && textBeforeSelection !== '') { // Check start of line for whether we're in an empty ordered list const lastNewLine = getLastIndexOfCharacter({ @@ -479,9 +522,9 @@ export const coreSyntaxFormatters = { if (selectedText === '') { // Otherwise insert an empty list for an empty selection return { - newTextAreaValue: `${textBeforeSelection}\n\n1. \n${textAfterSelection}`, - newCursorStart: selectionStart + 5, - newCursorEnd: selectionEnd + 5, + newTextAreaValue: `${textBeforeSelection}${newLinesPrefix}1. ${newLinesSuffix}${textAfterSelection}`, + newCursorStart: selectionStart + 3 + newLinePrefixLength, + newCursorEnd: selectionEnd + 3 + newLinePrefixLength, }; } @@ -508,15 +551,18 @@ export const coreSyntaxFormatters = { }; } // Otherwise convert to an ordered list - const formattedList = `\n\n${splitByNewLine + const formattedList = `${newLinesPrefix}${splitByNewLine .map((textChunk, index) => `${index + 1}. ${textChunk}`) - .join('\n')}\n`; + .join('\n')}${newLinesSuffix}`; + + const cursorOffsetStart = + selectedText.length === 0 ? 4 : newLinePrefixLength; return { newTextAreaValue: `${textBeforeSelection}${formattedList}${textAfterSelection}`, - newCursorStart: selectionStart + (selectedText.length === 0 ? 4 : 2), + newCursorStart: selectionStart + cursorOffsetStart, newCursorEnd: - selectionEnd + formattedList.length - selectedText.length - 1, + selectionStart + formattedList.length - newLineSuffixLength, }; }, }, @@ -578,13 +624,20 @@ export const coreSyntaxFormatters = { }; } + const { newLinesPrefix, newLinesSuffix } = getNewLinePrefixSuffixes({ + selectionStart, + selectionEnd, + value, + }); + const { length: newLinePrefixLength } = newLinesPrefix; + const adjustingHeading = currentHeadingIndex > 0; - const cursorOffset = adjustingHeading ? 1 : 5; + const cursorOffset = adjustingHeading ? 1 : 3 + newLinePrefixLength; return { newTextAreaValue: adjustingHeading ? `${textBeforeSelection}#${selectedText}${textAfterSelection}` - : `${textBeforeSelection}\n\n## ${selectedText}\n${textAfterSelection}`, + : `${textBeforeSelection}${newLinesPrefix}## ${selectedText}${newLinesSuffix}${textAfterSelection}`, newCursorStart: selectionStart + cursorOffset, newCursorEnd: selectionEnd + cursorOffset, }; diff --git a/app/javascript/utilities/__tests__/textAreaUtils.test.js b/app/javascript/utilities/__tests__/textAreaUtils.test.js index 665221d65..fe9efc307 100644 --- a/app/javascript/utilities/__tests__/textAreaUtils.test.js +++ b/app/javascript/utilities/__tests__/textAreaUtils.test.js @@ -3,6 +3,8 @@ import { getLastIndexOfCharacter, getNextIndexOfCharacter, getSelectionData, + getNumberOfNewLinesPrecedingSelection, + getNumberOfNewLinesFollowingSelection, } from '../textAreaUtils'; describe('getMentionWordData', () => { @@ -188,3 +190,97 @@ describe('getSelectionData', () => { }); }); }); + +describe('getNumberOfNewLinesPrecedingSelection', () => { + it('returns 0 when selection start is 0', () => { + expect( + getNumberOfNewLinesPrecedingSelection({ + selectionStart: 0, + value: 'some text', + }), + ).toEqual(0); + }); + + it('returns 0 if no new lines exist before selection', () => { + expect( + getNumberOfNewLinesPrecedingSelection({ + selectionStart: 9, + value: 'some text', + }), + ).toEqual(0); + }); + + it('returns count of new lines before selection', () => { + expect( + getNumberOfNewLinesPrecedingSelection({ + selectionStart: 6, + value: 'some\n\ntext', + }), + ).toEqual(2); + }); + + it('only returns count of new lines immediately before selection', () => { + expect( + getNumberOfNewLinesPrecedingSelection({ + selectionStart: 7, + value: 'some\n\ntext', + }), + ).toEqual(0); + }); + + it('stops counting new lines as soon as any other character occurs', () => { + expect( + getNumberOfNewLinesPrecedingSelection({ + selectionStart: 9, + value: '\n\n\nsome\n\ntext', + }), + ).toEqual(2); + }); +}); + +describe('getNumberOfNewLinesFollowingSelection', () => { + it('returns 0 when selection end is end of text area value', () => { + expect( + getNumberOfNewLinesFollowingSelection({ + selectionEnd: 9, + value: 'some text', + }), + ).toEqual(0); + }); + + it('returns 0 if no new lines exist after selection', () => { + expect( + getNumberOfNewLinesFollowingSelection({ + selectionEnd: 1, + value: 'some text', + }), + ).toEqual(0); + }); + + it('returns count of new lines after selection', () => { + expect( + getNumberOfNewLinesFollowingSelection({ + selectionEnd: 4, + value: 'some\n\ntext', + }), + ).toEqual(2); + }); + + it('only returns count of new lines immediately after selection', () => { + expect( + getNumberOfNewLinesFollowingSelection({ + selectionEnd: 1, + value: 'some\n\ntext', + }), + ).toEqual(0); + }); + + it('stops counting new lines as soon as any other character occurs', () => { + expect( + getNumberOfNewLinesFollowingSelection({ + selectionEnd: 0, + value: '\n\n\nsome\n\ntext', + }), + ).toEqual(3); + }); +}); diff --git a/app/javascript/utilities/textAreaUtils.js b/app/javascript/utilities/textAreaUtils.js index 3e0de2b19..a2fff5349 100644 --- a/app/javascript/utilities/textAreaUtils.js +++ b/app/javascript/utilities/textAreaUtils.js @@ -175,6 +175,61 @@ export const getNextIndexOfCharacter = ({ return -1; }; +/** + * Counts how many new lines come immediately before the user's current selection start + * @param {object} args + * @param {number} args.selectionStart The index of user's current selection start + * @param {string} args.value The value of the textarea + * + * @returns {number} Number of new lines directly before selection start + */ +export const getNumberOfNewLinesPrecedingSelection = ({ + selectionStart, + value, +}) => { + if (selectionStart === 0) { + return 0; + } + + let count = 0; + let searchIndex = selectionStart - 1; + + while (searchIndex >= 0 && value.charAt(searchIndex) === '\n') { + count++; + searchIndex--; + } + + return count; +}; + +/** + * Counts how many new lines come immediately after the user's current selection end + * + * @param {object} args + * @param {number} args.selectionEnd The index of user's current selection end + * @param {string} args.value The value of the textarea + * + * @returns {number} the count of new line characters immediately following selection + */ +export const getNumberOfNewLinesFollowingSelection = ({ + selectionEnd, + value, +}) => { + if (selectionEnd === value.length) { + return 0; + } + + let count = 0; + let searchIndex = selectionEnd; + + while (searchIndex < value.length && value.charAt(searchIndex) === '\n') { + count++; + searchIndex++; + } + + return count; +}; + /** * Retrieve data about the user's current text selection *