Skip to content

Commit

Permalink
Merge pull request #268 from bustlelabs/text-parser-263
Browse files Browse the repository at this point in the history
Add text parser, use it for handling pasted text
  • Loading branch information
bantic committed Dec 16, 2015
2 parents a23670d + c3e2ffd commit e003067
Show file tree
Hide file tree
Showing 6 changed files with 364 additions and 37 deletions.
4 changes: 4 additions & 0 deletions src/js/parsers/html.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
95 changes: 95 additions & 0 deletions src/js/parsers/text.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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 UL_LI_REGEX = /^\* (.*)$/;
const OL_LI_REGEX = /^\d\.? (.*)$/;
const CR = '\r';
const LF = '\n';
const CR_REGEX = new RegExp(CR, 'g');
const CR_LF_REGEX = new RegExp(CR+LF, 'g');

export const SECTION_BREAK = LF;

function normalizeLindEndings(text) {
return text.replace(CR_LF_REGEX, LF)
.replace(CR_REGEX, LF);
}

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 = normalizeLindEndings(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;
}
}
}
68 changes: 48 additions & 20 deletions src/js/utils/paste-utils.js
Original file line number Diff line number Diff line change
@@ -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='(.*?)'>/);
export const MIME_TEXT_PLAIN = 'text/plain';
export 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;
Expand All @@ -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 =
`<div data-mobiledoc='${JSON.stringify(mobiledoc)}'>${innerHTML}</div>`;
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, builder, plugins);
} else {
post = new HTMLParser(builder, {plugins}).parse(html);
post = parsePostFromHTML(html, builder, plugins);
}

return post;
Expand Down
75 changes: 58 additions & 17 deletions tests/acceptance/editor-copy-paste-test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { Editor } from 'mobiledoc-kit';
import Helpers from '../test-helpers';
import Range from 'mobiledoc-kit/utils/cursor/range';
import {
MIME_TEXT_PLAIN,
MIME_TEXT_HTML
} from 'mobiledoc-kit/utils/paste-utils';

const { test, module } = Helpers;

Expand All @@ -18,7 +22,11 @@ module('Acceptance: editor: copy-paste', {
editorElement = $('#editor')[0];
},
afterEach() {
if (editor) { editor.destroy(); }
if (editor) {
editor.destroy();
editor = null;
}
Helpers.dom.clearCopyData();
}
});

Expand All @@ -42,7 +50,25 @@ test('simple copy-paste at end of section works', (assert) => {
assert.hasElement('#editor p:contains(abcabc)', 'pastes the text');
});

test('paste from external text source', (assert) => {
test('paste plain text', (assert) => {
const mobiledoc = Helpers.mobiledoc.build(
({post, markupSection, marker}) => {
return post([markupSection('p', [marker('abc')])]);
});
editor = new Editor({mobiledoc});
editor.render(editorElement);

let textNode = $('#editor p')[0].childNodes[0];
assert.equal(textNode.textContent, 'abc'); //precond
Helpers.dom.moveCursorTo(textNode, textNode.length);

Helpers.dom.setCopyData(MIME_TEXT_PLAIN, 'abc');
Helpers.dom.triggerPasteEvent(editor);

assert.hasElement('#editor p:contains(abcabc)', 'pastes the text');
});

test('paste plain text with line breaks', (assert) => {
const mobiledoc = Helpers.mobiledoc.build(
({post, markupSection, marker}) => {
return post([markupSection('p', [marker('abc')])]);
Expand All @@ -54,10 +80,31 @@ test('paste from external text source', (assert) => {
assert.equal(textNode.textContent, 'abc'); //precond
Helpers.dom.moveCursorTo(textNode, textNode.length);

Helpers.dom.setCopyData('text/plain', 'abc');
Helpers.dom.setCopyData(MIME_TEXT_PLAIN, ['abc', 'def'].join('\n'));
Helpers.dom.triggerPasteEvent(editor);

assert.hasElement('#editor p:contains(abcabc)', 'pastes the text');
assert.hasElement('#editor p:contains(def)', 'second section is pasted');
assert.equal($('#editor p').length, 2, 'adds a second section');
});

test('paste plain text with list items', (assert) => {
const mobiledoc = Helpers.mobiledoc.build(
({post, markupSection, marker}) => {
return post([markupSection('p', [marker('abc')])]);
});
editor = new Editor({mobiledoc});
editor.render(editorElement);

let textNode = $('#editor p')[0].childNodes[0];
assert.equal(textNode.textContent, 'abc'); //precond
Helpers.dom.moveCursorTo(textNode, textNode.length);

Helpers.dom.setCopyData(MIME_TEXT_PLAIN, ['* abc', '* def'].join('\n'));
Helpers.dom.triggerPasteEvent(editor);

assert.hasElement('#editor p:contains(abcabc)', 'pastes the text');
assert.hasElement('#editor ul li:contains(def)', 'list item is pasted');
});

test('can cut and then paste content', (assert) => {
Expand Down Expand Up @@ -253,20 +300,14 @@ test('copy sets html & text for pasting externally', (assert) => {

Helpers.dom.triggerCopyEvent(editor);

let text = Helpers.dom.getCopyData('text/plain');
let html = Helpers.dom.getCopyData('text/html');
assert.equal(text, [
"heading",
"h2 subheader",
"The text"
].join('\n'), 'gets plain text');

assert.ok(html.indexOf("<h1>heading") !== -1,
'html has h1');
assert.ok(html.indexOf("<h2>h2 subheader") !== -1,
'html has h2');
assert.ok(html.indexOf("<p>The text") !== -1,
'html has p');
let text = Helpers.dom.getCopyData(MIME_TEXT_PLAIN);
let html = Helpers.dom.getCopyData(MIME_TEXT_HTML);
assert.equal(text, ["heading", "h2 subheader", "The text" ].join('\n'),
'gets plain text');

assert.ok(html.indexOf("<h1>heading") !== -1, 'html has h1');
assert.ok(html.indexOf("<h2>h2 subheader") !== -1, 'html has h2');
assert.ok(html.indexOf("<p>The text") !== -1, 'html has p');
});

test('pasting when on the end of a card is blocked', (assert) => {
Expand Down
7 changes: 7 additions & 0 deletions tests/helpers/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,12 @@ function setCopyData(type, value) {
lastCopyData[type] = value;
}

function clearCopyData() {
Object.keys(lastCopyData).forEach(key => {
delete lastCopyData[key];
});
}

function fromHTML(html) {
html = $.trim(html);
let div = document.createElement('div');
Expand Down Expand Up @@ -327,6 +333,7 @@ const DOMHelper = {
triggerPasteEvent,
getCopyData,
setCopyData,
clearCopyData,
createMockEvent
};

Expand Down
Loading

0 comments on commit e003067

Please sign in to comment.