Skip to content

Commit

Permalink
Add text parser, use it for handling pasted text
Browse files Browse the repository at this point in the history
The text parser recognizes multiple sections for text separated by
line breaks and will parse lists when lines of text start with "*"
(unordered) or "<number>." (ordered).

fixes #263
  • Loading branch information
bantic committed Dec 16, 2015
1 parent e687d3d commit 5f07e06
Show file tree
Hide file tree
Showing 6 changed files with 305 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
84 changes: 84 additions & 0 deletions src/js/parsers/text.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
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
56 changes: 39 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,12 @@ 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('can cut and then paste content', (assert) => {
Expand Down Expand Up @@ -253,20 +281,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 5f07e06

Please sign in to comment.