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');