From a677599179e0608df649040e79a66ec37ac41018 Mon Sep 17 00:00:00 2001 From: Matthew Beale Date: Thu, 3 Dec 2015 16:46:18 -0500 Subject: [PATCH] Add support for tabs Tabs are represented by tab characters in Mobiledoc, ala \t, and in rendered DOM are represened as an emspace: \u2003 Additionally fix an issue with white-space only markers being the first thing in a section. They were disregarded by the renderer, errantly. --- src/js/editor/editor.js | 25 +++++++++++++++++------ src/js/editor/post.js | 23 +++++++++++++++++++++ src/js/models/marker.js | 6 +----- src/js/parsers/dom.js | 14 +++++++++++-- src/js/renderers/editor-dom.js | 7 ++++--- src/js/utils/characters.js | 1 + src/js/utils/key.js | 5 +++++ tests/acceptance/basic-editor-test.js | 27 +++++++++++++++++++++++++ tests/unit/parsers/dom-test.js | 9 +++++++++ tests/unit/renderers/editor-dom-test.js | 14 +++++++++++++ 10 files changed, 115 insertions(+), 16 deletions(-) create mode 100644 src/js/utils/characters.js diff --git a/src/js/editor/editor.js b/src/js/editor/editor.js index feb3880a7..d09e9dae4 100644 --- a/src/js/editor/editor.js +++ b/src/js/editor/editor.js @@ -36,6 +36,7 @@ import { setClipboardCopyData } from '../utils/paste-utils'; import { DIRECTION } from 'mobiledoc-kit/utils/key'; +import { TAB } from 'mobiledoc-kit/utils/characters'; export const EDITOR_ELEMENT_CLASS_NAME = '__mobiledoc-editor'; @@ -598,8 +599,7 @@ class Editor { this._insertEmptyMarkupSectionAtCursor(); } - const key = Key.fromEvent(event); - + let key = Key.fromEvent(event); let range, nextPosition; switch(true) { @@ -630,11 +630,24 @@ class Editor { this.handleNewline(event); break; case key.isPrintable(): - let range = this.cursor.offsets; - if (this.cursor.hasSelection()) { - let nextPosition = this.run(postEditor => postEditor.deleteRange(range)); + let { offsets: range } = this.cursor; + let { isCollapsed } = range; + let nextPosition = range.head; + this.run(postEditor => { + if (!isCollapsed) { + nextPosition = postEditor.deleteRange(range); + } + if (key.isTab() && !range.head.section.isCardSection) { + nextPosition = postEditor.insertText(nextPosition, TAB); + } + }); + if (nextPosition !== range.head) { this.cursor.moveToPosition(nextPosition); - } else if (range.head.section.isCardSection) { + } + if ( + (isCollapsed && range.head.section.isCardSection) || + key.isTab() + ) { event.preventDefault(); } break; diff --git a/src/js/editor/post.js b/src/js/editor/post.js index 35e87cfc1..ae54d3b63 100644 --- a/src/js/editor/post.js +++ b/src/js/editor/post.js @@ -674,6 +674,29 @@ class PostEditor { return this.moveSectionBefore(collection, renderedSection, beforeSection); } + insertText(position, text) { + let section = position.section; + if (!section.isMarkerable) { + return; + } + + let {marker,offset} = position.markerPosition; + let nextPosition = position; + if (!marker) { + marker = this.builder.createMarker(text); + section.markers.append(marker); + this._markDirty(section); + nextPosition = new Position(section, 1); + } else if (marker) { + let markerHead = marker.value.slice(0, offset); + let markerTail = marker.value.slice(offset, marker.length); + marker.value = `${markerHead}${text}${markerTail}`; + this._markDirty(marker); + nextPosition = position.moveRight(); + } + return nextPosition; + } + _replaceSection(section, newSections) { let nextSection = section.next; let collection = section.parent.sections; diff --git a/src/js/models/marker.js b/src/js/models/marker.js index 82485a5a2..b4490c4f3 100644 --- a/src/js/models/marker.js +++ b/src/js/models/marker.js @@ -4,10 +4,6 @@ import { normalizeTagName } from '../utils/dom-utils'; import { detect, commonItemLength, forEach, filter } from '../utils/array-utils'; import LinkedItem from '../utils/linked-item'; -function trim(str) { - return str.replace(/^\s+/, '').replace(/\s+$/, ''); -} - const Marker = class Marker extends LinkedItem { constructor(value='', markups=[]) { super(); @@ -27,7 +23,7 @@ const Marker = class Marker extends LinkedItem { } get isBlank() { - return trim(this.value).length === 0; + return this.value.length === 0; } get length() { diff --git a/src/js/parsers/dom.js b/src/js/parsers/dom.js index 57b5dc14d..055c2e380 100644 --- a/src/js/parsers/dom.js +++ b/src/js/parsers/dom.js @@ -1,4 +1,7 @@ -import { NO_BREAK_SPACE } from '../renderers/editor-dom'; +import { + NO_BREAK_SPACE, + TAB_CHARACTER +} from '../renderers/editor-dom'; import { MARKUP_SECTION_TYPE, LIST_SECTION_TYPE, @@ -12,6 +15,7 @@ import { detect, forEach, } from '../utils/array-utils'; +import { TAB } from 'mobiledoc-kit/utils/characters'; import SectionParser from 'mobiledoc-kit/parsers/section'; import { getAttributes, walkTextNodes } from '../utils/dom-utils'; @@ -20,9 +24,11 @@ import Markup from 'mobiledoc-kit/models/markup'; const GOOGLE_DOCS_CONTAINER_ID_REGEX = /^docs\-internal\-guid/; const NO_BREAK_SPACE_REGEX = new RegExp(NO_BREAK_SPACE, 'g'); +const TAB_CHARACTER_REGEX = new RegExp(TAB_CHARACTER, 'g'); export function transformHTMLText(textContent) { let text = textContent; text = text.replace(NO_BREAK_SPACE_REGEX, ' '); + text = text.replace(TAB_CHARACTER_REGEX, TAB); return text; } @@ -54,6 +60,10 @@ function remapTagName(tagName) { return remapped || normalized; } +function trim(str) { + return str.replace(/^\s+/, '').replace(/\s+$/, ''); +} + /** * Parses DOM element -> Post */ @@ -80,7 +90,7 @@ export default class DOMParser { } appendSection(post, section) { - if (section.isBlank) { + if (section.isBlank || (section.isMarkerable && trim(section.text) === '')) { return; } diff --git a/src/js/renderers/editor-dom.js b/src/js/renderers/editor-dom.js index 558e07818..df7daf0a7 100644 --- a/src/js/renderers/editor-dom.js +++ b/src/js/renderers/editor-dom.js @@ -13,9 +13,11 @@ import { startsWith, endsWith } from '../utils/string-utils'; import { addClassName } from '../utils/dom-utils'; import { MARKUP_SECTION_ELEMENT_NAMES } from '../models/markup-section'; import assert from '../utils/assert'; +import { TAB } from 'mobiledoc-kit/utils/characters'; const CARD_ELEMENT_CLASS_NAME = '__mobiledoc-card'; export const NO_BREAK_SPACE = '\u00A0'; +export const TAB_CHARACTER = '\u2003'; export const SPACE = ' '; function createElementFromMarkup(doc, markup) { @@ -40,9 +42,8 @@ function renderHTMLText(marker) { } else if ((!marker.prev || endsWith(marker.prev.value, SPACE)) && startsWith(text, SPACE)) { text = NO_BREAK_SPACE + text.substr(1); } - text = text.replace(/ ( )/g, () => { - return ' '+NO_BREAK_SPACE; - }); + text = text.replace(/ ( )/g, ' '+NO_BREAK_SPACE); + text = text.replace(new RegExp(TAB, 'g'), TAB_CHARACTER); return text; } diff --git a/src/js/utils/characters.js b/src/js/utils/characters.js new file mode 100644 index 000000000..def33662b --- /dev/null +++ b/src/js/utils/characters.js @@ -0,0 +1 @@ +export const TAB = '\t'; diff --git a/src/js/utils/key.js b/src/js/utils/key.js index 3fac14550..7a4c893c7 100644 --- a/src/js/utils/key.js +++ b/src/js/utils/key.js @@ -108,6 +108,10 @@ const Key = class Key { return this.keyCode === Keycodes.SPACE; } + isTab() { + return this.keyCode === Keycodes.TAB; + } + isEnter() { return this.keyCode === Keycodes.ENTER; } @@ -158,6 +162,7 @@ const Key = class Key { return ( (code >= Keycodes['0'] && code <= Keycodes['9']) || // number keys this.isSpace() || + this.isTab() || this.isEnter() || (code >= Keycodes.A && code <= Keycodes.Z) || // letter keys (code >= Keycodes.NUMPAD_0 && code <= Keycodes.NUMPAD_9) || // numpad keys diff --git a/tests/acceptance/basic-editor-test.js b/tests/acceptance/basic-editor-test.js index 722cc8c4b..f71340a78 100644 --- a/tests/acceptance/basic-editor-test.js +++ b/tests/acceptance/basic-editor-test.js @@ -1,5 +1,6 @@ import { Editor } from 'mobiledoc-kit'; import Helpers from '../test-helpers'; +import { TAB } from 'mobiledoc-kit/utils/characters'; const { test, module } = Helpers; @@ -147,6 +148,32 @@ test('typing when on the start of a card is blocked', (assert) => { assert.hasNoElement('#editor div:contains(Y)'); }); +test('typing tab enters a tab character', (assert) => { + let done = assert.async(); + let mobiledoc = Helpers.mobiledoc.build(({post}) => post()); + editor = new Editor({mobiledoc}); + editor.render(editorElement); + + assert.hasElement('#editor'); + assert.hasNoElement('#editor p'); + + Helpers.dom.moveCursorTo($('#editor')[0]); + Helpers.dom.insertText(editor, TAB); + Helpers.dom.insertText(editor, 'Y'); + window.setTimeout(() => { + let editedMobiledoc = editor.serialize(); + assert.deepEqual(editedMobiledoc.sections, [ + [], + [ + [1, 'p', [ + [[], 0, `${TAB}Y`] + ]] + ] + ], 'correctly encoded'); + done(); + }, 0); +}); + // see https://github.com/bustlelabs/mobiledoc-kit/issues/215 test('select-all and type text works ok', (assert) => { let done = assert.async(); diff --git a/tests/unit/parsers/dom-test.js b/tests/unit/parsers/dom-test.js index fdf6a9e16..66e222699 100644 --- a/tests/unit/parsers/dom-test.js +++ b/tests/unit/parsers/dom-test.js @@ -3,6 +3,7 @@ import PostNodeBuilder from 'mobiledoc-kit/models/post-node-builder'; import Helpers from '../../test-helpers'; import { Editor } from 'mobiledoc-kit'; import { NO_BREAK_SPACE } from 'mobiledoc-kit/renderers/editor-dom'; +import { TAB } from 'mobiledoc-kit/utils/characters'; const {module, test} = Helpers; @@ -61,6 +62,14 @@ test('#parse can parse spaces and breaking spaces', (assert) => { assert.equal(s1.markers.head.value, 'some text for you', 'has text'); }); +test('#parse can parse tabs', (assert) => { + let element = buildDOM("

a\u2003b

"); + let post = parser.parse(element); + let s1 = post.sections.head; + assert.equal(s1.markers.length, 1, 's1 has 1 marker'); + assert.equal(s1.markers.head.value, `a${TAB}b`); +}); + test('editor#reparse catches changes to section', (assert) => { const mobiledoc = Helpers.mobiledoc.build(({post, markupSection, marker}) => post([ diff --git a/tests/unit/renderers/editor-dom-test.js b/tests/unit/renderers/editor-dom-test.js index dbfcd2308..a2a2a44b5 100644 --- a/tests/unit/renderers/editor-dom-test.js +++ b/tests/unit/renderers/editor-dom-test.js @@ -3,6 +3,7 @@ import Renderer from 'mobiledoc-kit/renderers/editor-dom'; import RenderTree from 'mobiledoc-kit/models/render-tree'; import Helpers from '../../test-helpers'; import { NO_BREAK_SPACE } from 'mobiledoc-kit/renderers/editor-dom'; +import { TAB } from 'mobiledoc-kit/utils/characters'; const { module, test } = Helpers; const ZWNJ = '\u200c'; @@ -126,6 +127,19 @@ test('renders a post with marker', (assert) => { assert.equal(renderTree.rootElement.innerHTML, '

Hi

'); }); +test('renders a post with marker with a tab', (assert) => { + let post = Helpers.postAbstract.build(({post, markupSection, marker}) => { + return post([ + markupSection('p', [marker(`a${TAB}b`)]) + ]); + }); + + const renderTree = new RenderTree(post); + render(renderTree); + assert.equal(renderTree.rootElement.innerHTML, '

a\u2003b

', + 'HTML for a tab character is correct'); +}); + test('renders a post with markup empty section', (assert) => { let post = builder.createPost(); let section = builder.createMarkupSection('P');