diff --git a/src/components/RichText/RichTextField.tsx b/src/components/RichText/RichTextField.tsx index 4899de4226..08cfbbfa19 100644 --- a/src/components/RichText/RichTextField.tsx +++ b/src/components/RichText/RichTextField.tsx @@ -36,7 +36,7 @@ import { getHelperText, showError } from '../form/util'; import { FormattedNumber } from '../Formatters'; import { EditorJsTheme } from './EditorJsTheme'; import type { ToolKey } from './editorJsTools'; -import { handleMsUnorderedList } from './ms-word-helpers'; +import { handleMsPasteFormatting } from './ms-word-helpers'; import { RichTextView } from './RichTextView'; declare module '@editorjs/editorjs/types/data-formats/output-data' { @@ -138,7 +138,7 @@ export function RichTextField({ 'paste', (event: ClipboardEvent) => { const formattedUl: RichTextData | undefined = - handleMsUnorderedList(event); + handleMsPasteFormatting(event); if (formattedUl) { event.preventDefault(); input.onChange(formattedUl); diff --git a/src/components/RichText/ms-word-helpers.ts b/src/components/RichText/ms-word-helpers.ts index 9676a9dd19..3886b73659 100644 --- a/src/components/RichText/ms-word-helpers.ts +++ b/src/components/RichText/ms-word-helpers.ts @@ -1,31 +1,78 @@ import { OutputData as RichTextData } from '@editorjs/editorjs'; -export const handleMsUnorderedList = ( +export const handleMsPasteFormatting = ( event: ClipboardEvent ): RichTextData | undefined => { const text = event.clipboardData?.getData('text'); + // If there is no text, or the text has no list return undefined and continue native paste propagation + if (!text || !hasWordListMarkers(text)) { + return; + } - // Match any line that starts with "•\t" followed by anything until the end of the line - const matches = text?.match(/•\t(.*)/g); + const parsedLines = text.split('\n').map((line) => { + if (isUnorderedList(line)) { + return { type: 'ul', text: line.replace(/•\t/, '') }; + } + if (isOrderedList(line)) { + return { type: 'ol', text: line.replace(/^\d+\.\s/, '') }; + } + if (line === '\r') { + return { type: 'break', text: line }; + } + return { type: 'p', text: line }; + }); - if (!matches) { - return undefined; - } - // If there are matches, prevent default actions, then create an array of strings without the "•\t" prefix - const listItems = matches.map((item) => item.replace(/•\t/, '')); + const groupedLines = groupSiblingsBy(parsedLines, (line) => line.type); + + const blocks = groupedLines.map((lines) => { + const type = lines[0]!.type; + const textLines = lines.map((line) => line.text); + + if (type === 'ol') { + return createListBlock(textLines, 'ordered'); + } + if (type === 'ul') { + return createListBlock(textLines, 'unordered'); + } + return createParagraphBlock(textLines); + }); - // Now create a new object that conforms to the structure of RichTextData - // Assumption is that all the list items should be part of a single unordered list return { time: Date.now(), - blocks: [ - { - type: 'list', - data: { - style: 'unordered', - items: listItems, - }, - }, - ], + blocks, }; }; + +const groupSiblingsBy = (items: readonly T[], by: (item: T) => unknown) => + items.reduce((acc: T[][], cur: T) => { + // If it's the first item or different from the last, start a new group + if (!acc.length || by(acc.at(-1)![0]!) !== by(cur)) { + acc.push([cur]); + } else { + // Otherwise, add it to the current group + acc.at(-1)!.push(cur); + } + return acc; + }, []); + +const createParagraphBlock = (text: string[]) => ({ + type: 'paragraph', + data: { + text: text.join(' '), + }, +}); + +const createListBlock = (items: string[], style: 'unordered' | 'ordered') => ({ + type: 'list', + data: { + style: style, + items: items, + }, +}); + +const hasWordListMarkers = (text: string) => + isUnorderedList(text) || isOrderedList(text); + +const isUnorderedList = (text: string) => /•\t(.*)/.test(text); + +const isOrderedList = (text: string) => /^\d+\.\s(.*)/.test(text);