diff --git a/src/js/editor/editor.js b/src/js/editor/editor.js index 91c053882..1c8f5a0de 100644 --- a/src/js/editor/editor.js +++ b/src/js/editor/editor.js @@ -308,6 +308,38 @@ class Editor { this._renderer.render(this._renderTree); } + deleteSelection(event) { + event.preventDefault(); + + // types of selection deletion: + // * a selection starts at the beginning of a section + // -- cursor should end up at the beginning of that section + // -- if the section not longer has markers, add a blank one for the cursor to focus on + // * a selection is entirely within a section + // -- split the markers with the selection, remove those new markers from their section + // -- cursor goes at end of the marker before the selection start, or if the + // -- selection was at the start of the section, cursor goes at section start + // * a selection crosses multiple sections + // -- remove all the sections that are between (exclusive ) selection start and end + // -- join the start and end sections + // -- mark the end section for removal + // -- cursor goes at end of marker before the selection start + + const markers = this.splitMarkersFromSelection(); + + const {changedSections, removedSections, currentMarker, currentOffset} = this.post.cutMarkers(markers); + + changedSections.forEach(section => section.renderNode.markDirty()); + removedSections.forEach(section => section.renderNode.scheduleForRemoval()); + + this.rerender(); + + let currentTextNode = currentMarker.renderNode.element; + this.cursor.moveToNode(currentTextNode, currentOffset); + + this.trigger('update'); + } + // FIXME ensure we handle deletion when there is a selection handleDeletion(event) { let { @@ -323,6 +355,11 @@ class Editor { // * C offset is 0 and there is no previous marker // * join this section with previous section + if (this.cursor.hasSelection()) { + this.deleteSelection(event); + return; + } + const currentMarker = leftRenderNode.postNode; let nextCursorMarker = currentMarker; let nextCursorOffset = leftOffset - 1; @@ -558,7 +595,6 @@ class Editor { this.hasSelection(); } - get cursor() { return new Cursor(this); } diff --git a/src/js/models/cursor.js b/src/js/models/cursor.js index d6c5e1ee8..f70f3c0d1 100644 --- a/src/js/models/cursor.js +++ b/src/js/models/cursor.js @@ -144,6 +144,12 @@ export default class Cursor { this.moveToNode(startNode, startOffset, endNode, endOffset); } + /** + * @param {textNode} node + * @param {integer} offset + * @param {textNode} endNode (default: node) + * @param {integer} endOffset (default: offset) + */ moveToNode(node, offset=0, endNode=node, endOffset=offset) { let r = document.createRange(); r.setStart(node, offset); diff --git a/src/js/models/marker.js b/src/js/models/marker.js index 70578c67f..5b4ff834b 100644 --- a/src/js/models/marker.js +++ b/src/js/models/marker.js @@ -6,6 +6,10 @@ import { import { detect } from 'content-kit-editor/utils/array-utils'; const Marker = class Marker { + static createBlank() { + return new Marker(''); + } + constructor(value='', markups=[]) { this.value = value; this.markups = []; diff --git a/src/js/models/markup-section.js b/src/js/models/markup-section.js index b0b97ff6c..24164668b 100644 --- a/src/js/models/markup-section.js +++ b/src/js/models/markup-section.js @@ -26,6 +26,10 @@ export default class Section { return this._tagName; } + isEmpty() { + return this.markers.length === 0; + } + setTagName(newTagName) { newTagName = normalizeTagName(newTagName); if (VALID_MARKUP_SECTION_TAGNAMES.indexOf(newTagName) === -1) { diff --git a/src/js/models/post.js b/src/js/models/post.js index 60f90a139..de496cecb 100644 --- a/src/js/models/post.js +++ b/src/js/models/post.js @@ -1,3 +1,4 @@ +import Marker from './marker'; export const POST_TYPE = 'post'; // FIXME: making sections a linked-list would greatly improve this @@ -19,6 +20,53 @@ export default class Post { this.insertSectionAfter(newSection, section); this.removeSection(section); } + cutMarkers(markers) { + let firstSection = markers[0].section, + lastSection = markers[markers.length - 1].section; + + let currentSection = firstSection; + let removedSections = [], + changedSections = [firstSection, lastSection]; + + let previousMarker = markers[0].previousSibling; + + markers.forEach(marker => { + if (marker.section !== currentSection) { // this marker is in a section we haven't seen yet + if (marker.section !== firstSection && + marker.section !== lastSection) { + // section is wholly contained by markers, and can be removed + removedSections.push(marker.section); + } + } + + currentSection = marker.section; + currentSection.removeMarker(marker); + }); + + // add a blank marker to any sections that are now empty + changedSections.forEach(section => { + if (section.isEmpty()) { + section.appendMarker(Marker.createBlank()); + } + }); + + let currentMarker, currentOffset; + + if (previousMarker) { + currentMarker = previousMarker; + currentOffset = currentMarker.length; + } else { + currentMarker = firstSection.markers[0]; + currentOffset = 0; + } + + if (firstSection !== lastSection) { + firstSection.join(lastSection); + removedSections.push(lastSection); + } + + return {changedSections, removedSections, currentMarker, currentOffset}; + } /** * Invoke `callbackFn` for all markers between the startMarker and endMarker (inclusive), * across sections @@ -34,7 +82,7 @@ export default class Post { currentMarker = currentMarker.nextSibling; } else { let nextSection = currentMarker.section.nextSibling; - currentMarker = nextSection.markers[0]; + currentMarker = nextSection && nextSection.markers[0]; } } } diff --git a/tests/acceptance/editor-selections-test.js b/tests/acceptance/editor-selections-test.js index 275f88615..b42dd85e3 100644 --- a/tests/acceptance/editor-selections-test.js +++ b/tests/acceptance/editor-selections-test.js @@ -54,3 +54,113 @@ test('selecting across sections is possible', (assert) => { done(); }); }); + +test('selecting an entire section and deleting removes it', (assert) => { + const done = assert.async(); + + editor = new Editor(editorElement, {mobiledoc: mobileDocWith2Sections}); + + Helpers.dom.selectText('second section', editorElement); + Helpers.dom.triggerKeyEvent(document, 'keydown', Helpers.dom.KEY_CODES.DELETE); + + assert.hasElement('p:contains(first section)'); + assert.hasNoElement('p:contains(second section)', 'deletes contents of second section'); + assert.equal($('#editor p').length, 2, 'still has 2 sections'); + + let textNode = editorElement + .childNodes[1] // second section p + .childNodes[0]; // textNode + + assert.deepEqual(Helpers.dom.getCursorPosition(), + {node: textNode, offset: 0}); + + done(); +}); + +test('selecting text in a section and deleting deletes it', (assert) => { + editor = new Editor(editorElement, {mobiledoc: mobileDocWith2Sections}); + + Helpers.dom.selectText('cond sec', editorElement); + Helpers.dom.triggerKeyEvent(document, 'keydown', Helpers.dom.KEY_CODES.DELETE); + + assert.hasElement('p:contains(first section)', 'first section unchanged'); + assert.hasNoElement('p:contains(second section)', 'second section is no longer there'); + assert.hasElement('p:contains(setion)', 'second section has correct text'); + + let textNode = $('p:contains(setion)')[0].childNodes[0]; + assert.equal(textNode.textContent, 'se', 'precond - has correct text node'); + let charOffset = 2; // after the 'e' in 'se' + + assert.deepEqual(Helpers.dom.getCursorPosition(), + {node: textNode, offset: charOffset}); +}); + +test('selecting text across sections and deleting joins sections', (assert) => { + editor = new Editor(editorElement, {mobiledoc: mobileDocWith2Sections}); + + const firstSection = $('#editor p')[0], + secondSection = $('#editor p')[1]; + + Helpers.dom.selectText('t section', firstSection, + 'second s', secondSection); + Helpers.dom.triggerKeyEvent(document, 'keydown', Helpers.dom.KEY_CODES.DELETE); + + assert.hasElement('p:contains(firsection)'); + assert.hasNoElement('p:contains(first section)'); + assert.hasNoElement('p:contains(second section)'); + assert.equal($('#editor p').length, 1, 'only 1 section after deleting to join'); +}); + +function getToolbarButton(assert, name) { + let btnSelector = `.ck-toolbar-btn[title="${name}"]`; + return assert.hasElement(btnSelector); +} + +function clickToolbarButton(assert, name) { + const button = getToolbarButton(assert, name); + Helpers.dom.triggerEvent(button[0], 'click'); +} + +test('selecting text across markers and deleting joins markers', (assert) => { + const done = assert.async(); + + editor = new Editor(editorElement, {mobiledoc: mobileDocWith2Sections}); + + Helpers.dom.selectText('rst sect', editorElement); + Helpers.dom.triggerEvent(document, 'mouseup'); + + setTimeout(() => { + clickToolbarButton(assert, 'bold'); + + let firstTextNode = editorElement + .childNodes[0] // p + .childNodes[1] // b + .childNodes[0]; // textNode containing "rst sect" + let secondTextNode = editorElement + .childNodes[0] // p + .childNodes[2]; // textNode containing "ion" + + assert.equal(firstTextNode.textContent, 'rst sect', 'correct first text node'); + assert.equal(secondTextNode.textContent, 'ion', 'correct second text node'); + Helpers.dom.selectText('t sect', firstTextNode, + 'ion', secondTextNode); + Helpers.dom.triggerKeyEvent(document, 'keydown', Helpers.dom.KEY_CODES.DELETE); + + assert.hasElement('p:contains(firs)', 'deletes across markers'); + assert.hasElement('strong:contains(rs)', 'maintains bold text'); + + firstTextNode = editorElement + .childNodes[0] // p + .childNodes[1] // b + .childNodes[0]; // textNode now containing "rs" + + assert.deepEqual(Helpers.dom.getCursorPosition(), + {node: firstTextNode, offset: 2}); + + done(); + }); +}); + +// test selecting text across markers deletes intermediary markers +// test selecting text that includes entire sections deletes the sections +// test selecting text and hitting enter or keydown