diff --git a/.changeset/funny-fishes-appear.md b/.changeset/funny-fishes-appear.md new file mode 100644 index 00000000000..0a7180ad32e --- /dev/null +++ b/.changeset/funny-fishes-appear.md @@ -0,0 +1,5 @@ +--- +"@primer/react": patch +--- + +MarkdownEditor & MarkdownViewer: Update new line identifier for formatting markdown diff --git a/src/__tests__/hooks/_useListEditing.test.tsx b/src/__tests__/hooks/_useListEditing.test.tsx new file mode 100644 index 00000000000..32d02de6fed --- /dev/null +++ b/src/__tests__/hooks/_useListEditing.test.tsx @@ -0,0 +1,98 @@ +import {parseListItem, listItemToString, ListItem} from '../../drafts/MarkdownEditor/_useListEditing' + +describe('parseListItem', () => { + it('should return null for a line that is not a list item', () => { + expect(parseListItem('This is a test line')).toBeNull() + }) + + it('should parse a line that is a numbered list item', () => { + expect(parseListItem('1. This is a test line')).toEqual({ + leadingWhitespace: '', + text: 'This is a test line', + delimeter: 1, + middleWhitespace: ' ', + taskBox: null, + }) + }) + + it('should parse a line that is a numbered list item and multiple spaces within', () => { + expect(parseListItem('1. This is a test line')).toEqual({ + leadingWhitespace: '', + text: 'This is a test line', + delimeter: 1, + middleWhitespace: ' ', + taskBox: null, + }) + }) + + it('should parse a line that is a bulleted list item', () => { + expect(parseListItem('* This is a test line')).toEqual({ + leadingWhitespace: '', + text: 'This is a test line', + delimeter: '*', + middleWhitespace: ' ', + taskBox: null, + }) + }) + + it('should parse a line that is a task list item', () => { + expect(parseListItem('- [x] This is a test line')).toEqual({ + leadingWhitespace: '', + text: 'This is a test line', + delimeter: '-', + middleWhitespace: ' ', + taskBox: '[x]', + }) + + // Up to 4 spaces are supported + expect(parseListItem('- [x] This is a test line')).toEqual({ + leadingWhitespace: '', + text: 'This is a test line', + delimeter: '-', + middleWhitespace: ' ', + taskBox: '[x]', + }) + + // 5 spaces are not supported + expect(parseListItem('- [x] This is a test line')).toEqual({ + leadingWhitespace: '', + text: ' [x] This is a test line', + delimeter: '-', + middleWhitespace: ' ', + taskBox: null, + }) + + // Tabs are supported + expect(parseListItem('- [x] This is a test line')).toEqual({ + leadingWhitespace: '', + text: 'This is a test line', + delimeter: '-', + middleWhitespace: ' ', + taskBox: '[x]', + }) + }) +}) + +describe('listItemToString', () => { + it('should convert a list item to a string', () => { + const item = { + leadingWhitespace: '', + text: 'This is a test line', + delimeter: 1, + middleWhitespace: ' ', + taskBox: null, + } + expect(listItemToString(item)).toBe('1. This is a test line') + }) + + it('should convert a task list item to a string', () => { + const item = { + leadingWhitespace: '', + text: 'This is a test line', + delimeter: '-', + middleWhitespace: ' ', + taskBox: '[x]', + } as ListItem + expect(listItemToString(item)).toBe('- [x] This is a test line') + }) +}) diff --git a/src/__tests__/hooks/_useListInteraction.test.tsx b/src/__tests__/hooks/_useListInteraction.test.tsx new file mode 100644 index 00000000000..7bb9a697e17 --- /dev/null +++ b/src/__tests__/hooks/_useListInteraction.test.tsx @@ -0,0 +1,43 @@ +import {parseCodeFenceBegin, isCodeFenceEnd} from '../../drafts/MarkdownViewer/_useListInteraction' + +describe('parseCodeFenceBegin', () => { + it('should return null for a line without a code fence', () => { + expect(parseCodeFenceBegin('This is a test line without a code fence')).toBeNull() + }) + + it('should return the code fence for a line with a code fence', () => { + expect(parseCodeFenceBegin('```This is a test line with a code fence')).toBe('```') + }) + + it('should return the code fence for a line with a code fence and leading spaces', () => { + expect(parseCodeFenceBegin(' ~~~This is a test line with a code fence and leading spaces')).toBe('~~~') + }) + + it('should return null for a line with more than 3 leading spaces before the code fence', () => { + expect( + parseCodeFenceBegin(' ```This is a test line with more than 3 leading spaces before the code fence'), + ).toBeNull() + }) +}) + +describe('isCodeFenceEnd', () => { + it('should return true for a line that ends a code fence', () => { + expect(isCodeFenceEnd('```', '```')).toBe(true) + }) + + it('should return false for a line that does not end a code fence', () => { + expect(isCodeFenceEnd('This is a test line', '```')).toBe(false) + }) + + it('should return true for a line that ends a code fence with leading spaces', () => { + expect(isCodeFenceEnd(' ~~~', '~~~')).toBe(true) + }) + + it('should return true for a line that ends a code fence with different new line characteres', () => { + expect(isCodeFenceEnd('~~~', '~~~')).toBe(true) + }) + + it('should return false for a line with more than 3 leading spaces before the code fence end', () => { + expect(isCodeFenceEnd(' ```', '```')).toBe(false) + }) +}) diff --git a/src/drafts/MarkdownEditor/_useIndenting.ts b/src/drafts/MarkdownEditor/_useIndenting.ts index abea48e79f5..a57ea51302a 100644 --- a/src/drafts/MarkdownEditor/_useIndenting.ts +++ b/src/drafts/MarkdownEditor/_useIndenting.ts @@ -29,7 +29,7 @@ export const useIndenting = ({emitChange}: UseIndentingSettings): UseIndentingRe const [start, end] = getSelectedLineRange(textarea) const updatedLines = textarea.value .slice(start, end) - .split('\n') + .split(/\r?\n/) .map(line => (event.shiftKey ? dedent(line) : indent(line))) .join('\n') diff --git a/src/drafts/MarkdownEditor/_useListEditing.ts b/src/drafts/MarkdownEditor/_useListEditing.ts index ac10fd94c2d..dd2712eb3e5 100644 --- a/src/drafts/MarkdownEditor/_useListEditing.ts +++ b/src/drafts/MarkdownEditor/_useListEditing.ts @@ -60,9 +60,9 @@ export const parseListItem = (line: string): ListItem | null => { } export const listItemToString = (item: ListItem) => - `${item.leadingWhitespace}${typeof item.delimeter === 'number' ? `${item.delimeter}.` : item.delimeter}${ - item.middleWhitespace - }${item.taskBox || ''} ${item.text}` + typeof item.delimeter === 'number' + ? `${item.leadingWhitespace}${`${item.delimeter}.`}${item.middleWhitespace}${item.text}` + : `${item.leadingWhitespace}${item.delimeter}${item.middleWhitespace}${item.taskBox || ''} ${item.text}` /** * Provides support for list editing in the Markdown editor. This includes inserting new @@ -79,7 +79,7 @@ export const useListEditing = ({emitChange}: UseListEditingSettings): UseListEdi // Strip off the leading newline by adding 1 const followingText = textarea.value.slice(currentLineEnd + 1) - const followingLines = followingText.split('\n') + const followingLines = followingText.split(/\r?\n/) const followingNumericListItems: Array = [] let prevItemNumber = currentLineItem.delimeter diff --git a/src/drafts/MarkdownViewer/MarkdownViewer.test.tsx b/src/drafts/MarkdownViewer/MarkdownViewer.test.tsx index c6db485a296..ac2f0c1c1f0 100644 --- a/src/drafts/MarkdownViewer/MarkdownViewer.test.tsx +++ b/src/drafts/MarkdownViewer/MarkdownViewer.test.tsx @@ -28,6 +28,7 @@ text before list - [ ] item 2 text after list` + const noItemsCheckedWithSpecialNewLineMarkdown = `\ntext before list\r\n\r\n- [ ] item 1\n- [ ] item 2\r\n\r\ntext after list` const hierarchyBeforeTaskListNoItemsChecked = ` text before list @@ -125,6 +126,21 @@ text after list` await waitFor(() => expect(onChangeMock).toHaveBeenCalledWith(firstItemCheckedMarkdown)) }) + it('calls `onChange` with the updated Markdown when a task is checked in a text with different new line chars', async () => { + const onChangeMock = jest.fn() + const {getAllByRole} = render( + , + ) + const items = getAllByRole('checkbox') + fireEvent.change(items[0]) + await waitFor(() => expect(onChangeMock).toHaveBeenCalledWith(firstItemCheckedMarkdown)) + }) + it('calls `onChange` with the updated Markdown when a task is checked and hierarchy is present', async () => { const onChangeMock = jest.fn() const {getAllByRole} = render( diff --git a/src/drafts/MarkdownViewer/_useListInteraction.ts b/src/drafts/MarkdownViewer/_useListInteraction.ts index ce1dad4ace0..03847489550 100644 --- a/src/drafts/MarkdownViewer/_useListInteraction.ts +++ b/src/drafts/MarkdownViewer/_useListInteraction.ts @@ -5,17 +5,17 @@ import {ListItem, listItemToString, parseListItem} from '../MarkdownEditor/_useL type TaskListItem = ListItem & {taskBox: '[ ]' | '[x]'} // Make check for code fences more robust per spec: https://github.github.com/gfm/#fenced-code-blocks -const parseCodeFenceBegin = (line: string) => { +export const parseCodeFenceBegin = (line: string) => { const match = line.match(/^ {0,3}(`{3,}|~{3,})[^`]*$/) return match ? match[1] : null } -const isCodeFenceEnd = (line: string, fence: string) => { +export const isCodeFenceEnd = (line: string, fence: string) => { const regex = new RegExp(`^ {0,3}${fence}${fence[0]}* *$`) return regex.test(line) } -const isTaskListItem = (item: ListItem | null): item is TaskListItem => typeof item?.taskBox === 'string' +export const isTaskListItem = (item: ListItem | null): item is TaskListItem => typeof item?.taskBox === 'string' const toggleTaskListItem = (item: TaskListItem): TaskListItem => ({ ...item, @@ -53,7 +53,7 @@ export const useListInteraction = ({ const onToggleItem = useCallback( (toggledItemIndex: number) => () => { - const lines = markdownRef.current.split('\n') + const lines = markdownRef.current.split(/\r?\n/) let currentCodeFence: string | null = null for (let lineIndex = 0, taskIndex = 0; lineIndex < lines.length; lineIndex++) {