From 4ae680369e568d54adcad5df3426fedc6991af86 Mon Sep 17 00:00:00 2001 From: Cory Forsyth Date: Wed, 16 Dec 2015 13:11:49 -0500 Subject: [PATCH] Add text parser, use it for handling pasted text The text parser recognizes multiple sections for text separated by line breaks and will parse lists when lines of text start with "*" (unordered) or "." (ordered). fixes #263 --- src/js/parsers/html.js | 4 ++ src/js/parsers/text.js | 84 ++++++++++++++++++++++ src/js/utils/paste-utils.js | 68 ++++++++++++------ tests/unit/parsers/text-test.js | 122 ++++++++++++++++++++++++++++++++ 4 files changed, 258 insertions(+), 20 deletions(-) create mode 100644 src/js/parsers/text.js create mode 100644 tests/unit/parsers/text-test.js diff --git a/src/js/parsers/html.js b/src/js/parsers/html.js index b2a452bc0..b12472761 100644 --- a/src/js/parsers/html.js +++ b/src/js/parsers/html.js @@ -9,6 +9,10 @@ export default class HTMLParser { this.options = options; } + /** + * @param {String} html to parse + * @return {Post} A post abstract + */ parse(html) { let dom = parseHTML(html); let parser = new DOMParser(this.builder, this.options); diff --git a/src/js/parsers/text.js b/src/js/parsers/text.js new file mode 100644 index 000000000..b77746336 --- /dev/null +++ b/src/js/parsers/text.js @@ -0,0 +1,84 @@ +import assert from 'mobiledoc-kit/utils/assert'; +import { + MARKUP_SECTION_TYPE, + LIST_SECTION_TYPE +} from 'mobiledoc-kit/models/types'; +import { + DEFAULT_TAG_NAME as DEFAULT_MARKUP_SECTION_TAG_NAME +} from 'mobiledoc-kit/models/markup-section'; + +const SECTION_BREAK = "\n"; +const UL_LI_REGEX = /^\* (.*)$/; +const OL_LI_REGEX = /^\d.? (.*)$/; + +export default class TextParser { + constructor(builder, options) { + this.builder = builder; + this.options = options; + + this.post = this.builder.createPost(); + this.prevSection = null; + } + + /** + * @param {String} text to parse + * @return {Post} a post abstract + */ + parse(text) { + text.split(SECTION_BREAK).forEach(text => { + let section = this._parseSection(text); + this._appendSection(section); + }); + + return this.post; + } + + _parseSection(text) { + let tagName = DEFAULT_MARKUP_SECTION_TAG_NAME, + type = MARKUP_SECTION_TYPE, + section; + + if (UL_LI_REGEX.test(text)) { + tagName = 'ul'; + type = LIST_SECTION_TYPE; + text = text.match(UL_LI_REGEX)[1]; + } else if (OL_LI_REGEX.test(text)) { + tagName = 'ol'; + type = LIST_SECTION_TYPE; + text = text.match(OL_LI_REGEX)[1]; + } + + let markers = [this.builder.createMarker(text)]; + + switch (type) { + case LIST_SECTION_TYPE: + let item = this.builder.createListItem(markers); + let list = this.builder.createListSection(tagName, [item]); + section = list; + break; + case MARKUP_SECTION_TYPE: + section = this.builder.createMarkupSection(tagName, markers); + break; + default: + assert(`Unknown type encountered ${type}`, false); + } + + return section; + } + + _appendSection(section) { + let isSameListSection = + section.isListSection && + this.prevSection && this.prevSection.isListSection && + this.prevSection.tagName === section.tagName; + + if (isSameListSection) { + section.items.forEach(item => { + this.prevSection.items.append(item.clone()); + }); + } else { + this.post.sections.insertAfter(section, this.prevSection); + this.prevSection = section; + } + } +} diff --git a/src/js/utils/paste-utils.js b/src/js/utils/paste-utils.js index 8ccf62504..d1605de6c 100644 --- a/src/js/utils/paste-utils.js +++ b/src/js/utils/paste-utils.js @@ -1,9 +1,39 @@ /* global JSON */ import mobiledocParsers from '../parsers/mobiledoc'; import HTMLParser from '../parsers/html'; +import TextParser from '../parsers/text'; import HTMLRenderer from 'mobiledoc-html-renderer'; import TextRenderer from 'mobiledoc-text-renderer'; +const MOBILEDOC_REGEX = new RegExp(/data\-mobiledoc='(.*?)'>/); +const MIME_TEXT_PLAIN = 'text/plain'; +const MIME_TEXT_HTML = 'text/html'; + +function parsePostFromHTML(html, builder, plugins) { + let post; + + if (MOBILEDOC_REGEX.test(html)) { + let mobiledocString = html.match(MOBILEDOC_REGEX)[1]; + let mobiledoc = JSON.parse(mobiledocString); + post = mobiledocParsers.parse(builder, mobiledoc); + } else { + post = new HTMLParser(builder, {plugins}).parse(html); + } + + return post; +} + +function parsePostFromText(text, builder, plugins) { + let parser = new TextParser(builder, {plugins}); + let post = parser.parse(text); + return post; +} + +/** + * @param {Event} copyEvent + * @param {Editor} + * @return null + */ export function setClipboardCopyData(copyEvent, editor) { const { cursor, post } = editor; const { clipboardData } = copyEvent; @@ -12,35 +42,33 @@ export function setClipboardCopyData(copyEvent, editor) { const mobiledoc = post.cloneRange(range); let unknownCardHandler = () => {}; // ignore unknown cards - let {result: innerHTML } = new HTMLRenderer({unknownCardHandler}) - .render(mobiledoc); + let {result: innerHTML } = + new HTMLRenderer({unknownCardHandler}).render(mobiledoc); const html = `
${innerHTML}
`; - const {result: plain} = new TextRenderer({unknownCardHandler}) - .render(mobiledoc); + const {result: plain} = + new TextRenderer({unknownCardHandler}).render(mobiledoc); - clipboardData.setData('text/plain', plain); - clipboardData.setData('text/html', html); + clipboardData.setData(MIME_TEXT_PLAIN, plain); + clipboardData.setData(MIME_TEXT_HTML, html); } +/** + * @param {Event} pasteEvent + * @param {PostNodeBuilder} builder + * @param {Array} plugins parser plugins + * @return {Post} + */ export function parsePostFromPaste(pasteEvent, builder, plugins=[]) { - let mobiledoc, post; - const mobiledocRegex = new RegExp(/data\-mobiledoc='(.*?)'>/); - - let html = pasteEvent.clipboardData.getData('text/html'); + let post; + let html = pasteEvent.clipboardData.getData(MIME_TEXT_HTML); - // Fallback to 'text/plain' - if (!html || html.length === 0) { - html = pasteEvent.clipboardData.getData('text/plain'); - } - - if (mobiledocRegex.test(html)) { - let mobiledocString = html.match(mobiledocRegex)[1]; - mobiledoc = JSON.parse(mobiledocString); - post = mobiledocParsers.parse(builder, mobiledoc); + if (!html || html.length === 0) { // Fallback to 'text/plain' + let text = pasteEvent.clipboardData.getData(MIME_TEXT_PLAIN); + post = parsePostFromText(text); } else { - post = new HTMLParser(builder, {plugins}).parse(html); + post = parsePostFromHTML(html, builder, plugins); } return post; diff --git a/tests/unit/parsers/text-test.js b/tests/unit/parsers/text-test.js new file mode 100644 index 000000000..96c3b2845 --- /dev/null +++ b/tests/unit/parsers/text-test.js @@ -0,0 +1,122 @@ +import TextParser from 'mobiledoc-kit/parsers/text'; +import PostNodeBuilder from 'mobiledoc-kit/models/post-node-builder'; +import Helpers from '../../test-helpers'; + +const {module, test} = Helpers; + +let editorElement, builder, parser, editor; + +module('Unit: Parser: TextParser', { + beforeEach() { + editorElement = $('#editor')[0]; + builder = new PostNodeBuilder(); + parser = new TextParser(builder); + }, + afterEach() { + builder = null; + parser = null; + if (editor) { + editor.destroy(); + editor = null; + } + } +}); + +test('#parse returns a markup section when given single line of text', (assert) => { + let text = 'some text'; + let post = parser.parse(text); + let expected = Helpers.postAbstract.build(({post, markupSection, marker}) => { + return post([markupSection('p', [marker('some text')])]); + }); + + assert.postIsSimilar(post, expected); +}); + +test('#parse returns multiple markup sections when given multiple lines', (assert) => { + let text = ['first section', 'second section'].join('\n'); + let post = parser.parse(text); + let expected = Helpers.postAbstract.build(({post, markupSection, marker}) => { + return post([ + markupSection('p', [marker('first section')]), + markupSection('p', [marker('second section')]) + ]); + }); + + assert.postIsSimilar(post, expected); +}); + +test('#parse returns list section when text starts with "*"', (assert) => { + let text = '* a list item'; + + let post = parser.parse(text); + let expected = Helpers.postAbstract.build(({post, listSection, listItem, marker}) => { + return post([ + listSection('ul', [listItem([marker('a list item')])]) + ]); + }); + + assert.postIsSimilar(post, expected); +}); + +test('#parse returns list section with multiple items when text starts with "*"', (assert) => { + let text = ['* first', '* second'].join('\n'); + + let post = parser.parse(text); + let expected = Helpers.postAbstract.build(({post, listSection, listItem, marker}) => { + return post([ + listSection('ul', [ + listItem([marker('first')]), + listItem([marker('second')]) + ]) + ]); + }); + + assert.postIsSimilar(post, expected); +}); + +test('#parse returns list sections separated by markup sections', (assert) => { + let text = ['* first list', 'middle section', '* second list'].join('\n'); + + let post = parser.parse(text); + let expected = Helpers.postAbstract.build( + ({post, listSection, listItem, markupSection, marker}) => { + return post([ + listSection('ul', [ + listItem([marker('first list')]) + ]), + markupSection('p', [marker('middle section')]), + listSection('ul', [ + listItem([marker('second list')]) + ]) + ]); + }); + + assert.postIsSimilar(post, expected); +}); + +test('#parse returns ordered list items', (assert) => { + let text = '1. first list'; + + let post = parser.parse(text); + let expected = Helpers.postAbstract.build( + ({post, listSection, listItem, markupSection, marker}) => { + return post([listSection('ol', [listItem([marker('first list')])])]); + }); + + assert.postIsSimilar(post, expected); +}); + +test('#parse can have ordered and unordered lists together', (assert) => { + let text = ['1. ordered list', '* unordered list'].join('\n'); + + let post = parser.parse(text); + let expected = Helpers.postAbstract.build( + ({post, listSection, listItem, markupSection, marker}) => { + return post([ + listSection('ol', [listItem([marker('ordered list')])]), + listSection('ul', [listItem([marker('unordered list')])]) + ]); + }); + + assert.postIsSimilar(post, expected); +});