diff --git a/src/js/commands/bold.js b/src/js/commands/bold.js index e9af24aaa..3ee143921 100644 --- a/src/js/commands/bold.js +++ b/src/js/commands/bold.js @@ -1,24 +1,36 @@ import TextFormatCommand from './text-format'; -import { getSelectionBlockTagName } from '../utils/selection-utils'; import { inherit } from 'content-kit-utils'; +import Markup from '../models/markup'; +import { + any +} from '../utils/array-utils'; -var RegExpHeadingTag = /^(h1|h2|h3|h4|h5|h6)$/i; - -function BoldCommand() { +function BoldCommand(editor) { TextFormatCommand.call(this, { name: 'bold', tag: 'strong', mappedTags: ['b'], button: '' }); + this.editor = editor; } inherit(BoldCommand, TextFormatCommand); BoldCommand.prototype.exec = function() { - // Don't allow executing bold command on heading tags - if (!RegExpHeadingTag.test(getSelectionBlockTagName())) { - BoldCommand._super.prototype.exec.call(this); - } + const markup = Markup.ofType('b'); + this.editor.applyMarkupToSelection(markup); +}; + +BoldCommand.prototype.isActive = function() { + let val = any(this.editor.activeMarkers, m => { + return any(this.mappedTags, tag => m.hasMarkup(tag)); + }); + return val; +}; + +BoldCommand.prototype.unexec = function() { + const markup = Markup.ofType('b'); + this.editor.removeMarkupFromSelection(markup); }; export default BoldCommand; diff --git a/src/js/editor/editor.js b/src/js/editor/editor.js index a4767db01..fd27193da 100644 --- a/src/js/editor/editor.js +++ b/src/js/editor/editor.js @@ -60,7 +60,6 @@ const defaults = { // in tests stickyToolbar: false, // !!('ontouchstart' in window), textFormatCommands: [ - new BoldCommand(), new ItalicCommand(), new LinkCommand() ], @@ -190,10 +189,14 @@ function makeButtons(editor) { const quoteCommand = new QuoteCommand(editor); const quoteButton = new ReversibleToolbarButton(quoteCommand, editor); + const boldCommand = new BoldCommand(editor); + const boldButton = new ReversibleToolbarButton(boldCommand, editor); + return [ headingButton, subheadingButton, - quoteButton + quoteButton, + boldButton ]; } @@ -399,7 +402,10 @@ class Editor { const markerRenderNode = leftRenderNode; const marker = markerRenderNode.postNode; const section = marker.section; - const [leftMarker, rightMarker] = marker.split(leftOffset); + const newMarkers = marker.split(leftOffset); + + // FIXME rightMarker is not guaranteed to be there + let [leftMarker, rightMarker] = newMarkers; section.insertMarkerAfter(leftMarker, marker); markerRenderNode.scheduleForRemoval(); @@ -458,11 +464,100 @@ class Editor { this.hasSelection(); } - getActiveSections() { - const cursor = this.cursor; - return cursor.activeSections; + /* + * @return {Array} of markers that are "inside the split" + */ + splitMarkersFromSelection() { + const { + startMarker, + leftOffset:startMarkerOffset, + endMarker, + rightOffset:endMarkerOffset, + startSection, + endSection + } = this.cursor.offsets; + + let selectedMarkers = []; + + startMarker.renderNode.scheduleForRemoval(); + endMarker.renderNode.scheduleForRemoval(); + + if (startMarker === endMarker) { + let newMarkers = startSection.splitMarker( + startMarker, startMarkerOffset, endMarkerOffset + ); + selectedMarkers = this.markersInOffset(newMarkers, startMarkerOffset, endMarkerOffset); + } else { + let newStartMarkers = startSection.splitMarker(startMarker, startMarkerOffset); + let selectedStartMarkers = this.markersInOffset(newStartMarkers, startMarkerOffset); + + let newEndMarkers = endSection.splitMarker(endMarker, endMarkerOffset); + let selectedEndMarkers = this.markersInOffset(newEndMarkers, 0, endMarkerOffset); + + let newStartMarker = selectedStartMarkers[0], + newEndMarker = selectedEndMarkers[selectedEndMarkers.length - 1]; + + this.post.markersFrom(newStartMarker, newEndMarker, m => selectedMarkers.push(m)); + } + + return selectedMarkers; + } + + markersInOffset(markers, startOffset, endOffset) { + let offset = 0; + let foundMarkers = []; + let toEnd = endOffset === undefined; + if (toEnd) { endOffset = 0; } + + markers.forEach(marker => { + if (toEnd) { + endOffset += marker.length; + } + + if (offset >= startOffset && offset < endOffset) { + foundMarkers.push(marker); + } + + offset += marker.length; + }); + + return foundMarkers; + } + + applyMarkupToSelection(markup) { + const markers = this.splitMarkersFromSelection(); + markers.forEach(marker => { + marker.addMarkup(markup); + marker.section.renderNode.markDirty(); + }); + + this.rerender(); + this.selectMarkers(markers); + this.didUpdate(); + } + + removeMarkupFromSelection(markup) { + const markers = this.activeMarkers; + // FIXME-NEXT Now we need to ensure we are using the singleton + // markup for the 'B' tag + // in order to get http://localhost:4200/tests/?testId=8cb07cab + // to pass + markers.forEach(marker => { + marker.removeMarkup(markup); + marker.section.renderNode.markDirty(); + }); + + this.rerender(); + this.selectMarkers(markers); + this.didUpdate(); + } + + selectMarkers(markers) { + this.cursor.selectMarkers(markers); + this.hasSelection(); } + get cursor() { return new Cursor(this); } @@ -615,6 +710,21 @@ class Editor { return this.cursor.activeSections; } + get activeMarkers() { + const { + startMarker, + endMarker, + } = this.cursor.offsets; + + if (!(startMarker && endMarker)) { + return []; + } + + let activeMarkers = []; + this.post.markersFrom(startMarker, endMarker, m => activeMarkers.push(m)); + return activeMarkers; + } + /* * Clear the markups from each of the section's markers */ diff --git a/src/js/models/cursor.js b/src/js/models/cursor.js index 0debefd27..d6c5e1ee8 100644 --- a/src/js/models/cursor.js +++ b/src/js/models/cursor.js @@ -34,7 +34,14 @@ export default class Cursor { get offsets() { let leftNode, rightNode, leftOffset, rightOffset; - const { anchorNode, focusNode, anchorOffset, focusOffset } = this.selection; + const selection = this.selection; + const { anchorNode, focusNode, anchorOffset, focusOffset } = selection; + const { rangeCount } = selection; + const range = rangeCount > 0 && selection.getRangeAt(0); + + if (!range) { + return {}; + } const position = anchorNode.compareDocumentPosition(focusNode); @@ -54,13 +61,23 @@ export default class Cursor { const leftRenderNode = this.renderTree.elements.get(leftNode), rightRenderNode = this.renderTree.elements.get(rightNode); + const startMarker = leftRenderNode && leftRenderNode.postNode, + endMarker = rightRenderNode && rightRenderNode.postNode; + + const startSection = startMarker && startMarker.section; + const endSection = endMarker && endMarker.section; + return { leftNode, rightNode, leftOffset, rightOffset, leftRenderNode, - rightRenderNode + rightRenderNode, + startMarker, + endMarker, + startSection, + endSection }; } @@ -116,6 +133,17 @@ export default class Cursor { this.moveToNode(startNode, startOffset, endNode, endOffset); } + selectMarkers(markers) { + const startMarker = markers[0], + endMarker = markers[markers.length - 1]; + + const startNode = startMarker.renderNode.element, + endNode = endMarker.renderNode.element; + const startOffset = 0, endOffset = endMarker.length; + + this.moveToNode(startNode, startOffset, endNode, endOffset); + } + 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 3b9ad35b8..80824c1b3 100644 --- a/src/js/models/marker.js +++ b/src/js/models/marker.js @@ -40,9 +40,9 @@ const Marker = class Marker { removeMarkup(markup) { const index = this.markups.indexOf(markup); - if (index === -1) { throw new Error('Cannot remove markup that is not there.'); } - - this.markups.splice(index, 1); + if (index !== -1) { + this.markups.splice(index, 1); + } } // delete the character at this offset, @@ -72,14 +72,27 @@ const Marker = class Marker { return joined; } - split(offset) { - const [m1, m2] = [ - new Marker(this.value.substr(0, offset)), - new Marker(this.value.substr(offset)) - ]; - this.markups.forEach(m => {m1.addMarkup(m); m2.addMarkup(m);}); + split(offset=0, endOffset=this.length) { + let markers = []; + + if (offset !== 0) { + markers.push( + new Marker(this.value.substring(0, offset)) + ); + } + + markers.push( + new Marker(this.value.substring(offset, endOffset)) + ); + + if (endOffset < this.length) { + markers.push( + new Marker(this.value.substring(endOffset)) + ); + } - return [m1, m2]; + this.markups.forEach(mu => markers.forEach(m => m.addMarkup(mu))); + return markers; } get openedMarkups() { @@ -88,6 +101,8 @@ const Marker = class Marker { } let i; for (i=0; i { }); }); }); + +test('click bold button applies bold to selected text', (assert) => { + const done = assert.async(); + + setTimeout(() => { + assertInactiveToolbarButton(assert, 'bold', 'precond - bold button is not active'); + clickToolbarButton(assert, 'bold'); + assertActiveToolbarButton(assert, 'bold'); + + assert.hasNoElement('#editor b:contains(THIS)'); + assert.hasNoElement('#editor b:contains(TEST)'); + assert.hasElement('#editor b:contains(IS A)'); + + assert.selectedText(selectedText); + + clickToolbarButton(assert, 'bold'); + + assert.hasNoElement('#editor b:contains(IS A)', 'bold text is no longer bold'); + assertInactiveToolbarButton(assert, 'bold'); + + done(); + }); +}); + +// test selecting across markers and boldening +// test selecting across markers in sections and bolding diff --git a/tests/unit/models/marker-test.js b/tests/unit/models/marker-test.js index 07e7d781b..f192dc9a6 100644 --- a/tests/unit/models/marker-test.js +++ b/tests/unit/models/marker-test.js @@ -75,7 +75,7 @@ test('a marker can be joined to another', (assert) => { assert.ok(m3.hasMarkup('i')); }); -test('a marker can be split into two', (assert) => { +test('#split splits a marker in 2 when no endOffset is passed', (assert) => { const m1 = new Marker('hi there!'); m1.addMarkup(new Markup('b')); @@ -86,3 +86,24 @@ test('a marker can be split into two', (assert) => { assert.equal(_m1.value, 'hi th'); assert.equal(m2.value, 'ere!'); }); + +test('#split splits a marker in 3 when endOffset is passed', (assert) => { + const m = new Marker('hi there!'); + m.addMarkup(new Markup('b')); + + const newMarkers = m.split(2, 4); + + assert.equal(newMarkers.length, 3, 'creates 3 new markers'); + newMarkers.forEach(m => assert.ok(m.hasMarkup('b'), 'marker has markup')); + + assert.equal(newMarkers[0].value, 'hi'); + assert.equal(newMarkers[1].value, ' t'); + assert.equal(newMarkers[2].value, 'here!'); +}); + +test('#split does not create an empty marker if the offset is 0', (assert) => { + const m = new Marker('hi there!'); + const newMarkers = m.split(0); + assert.equal(newMarkers.length, 1); + assert.equal(newMarkers[0].value, 'hi there!'); +}); diff --git a/tests/unit/models/section-test.js b/tests/unit/models/markup-section-test.js similarity index 76% rename from tests/unit/models/section-test.js rename to tests/unit/models/markup-section-test.js index 9fc949d03..03981973b 100644 --- a/tests/unit/models/section-test.js +++ b/tests/unit/models/markup-section-test.js @@ -4,7 +4,7 @@ import Section from 'content-kit-editor/models/markup-section'; import Marker from 'content-kit-editor/models/marker'; import Markup from 'content-kit-editor/models/markup'; -module('Unit: Section'); +module('Unit: Markup Section'); test('Section exists', (assert) => { assert.ok(Section); @@ -115,9 +115,38 @@ test('a section can be split, splitting its markers when multiple markers', (ass assert.equal(s2.markers[0].value, 'ere!'); }); -// test: a section can parse dom +test('#splitMarker splits the marker at the offset', (assert) => { + const m1 = new Marker('hi '); + const m2 = new Marker('there!'); + const s = new Section('h2', [m1,m2]); + + s.splitMarker(m2, 3); + assert.equal(s.markers.length, 3, 'adds a 3rd marker'); + assert.equal(s.markers[0].value, 'hi ', 'original marker unchanged'); + assert.equal(s.markers[1].value, 'the'); + assert.equal(s.markers[2].value, 're!'); +}); + +test('#splitMarker splits the marker at the end offset if provided', (assert) => { + const m1 = new Marker('hi '); + const m2 = new Marker('there!'); + const s = new Section('h2', [m1,m2]); + + s.splitMarker(m2, 1, 3); + assert.equal(s.markers.length, 4, 'adds a marker for the split and has one on each side'); + assert.equal(s.markers[0].value, 'hi ', 'original marker unchanged'); + assert.equal(s.markers[1].value, 't'); + assert.equal(s.markers[2].value, 'he'); + assert.equal(s.markers[3].value, 're!'); +}); + +test('#splitMarker does not create an empty marker if offset=0', (assert) => { + const m1 = new Marker('hi '); + const m2 = new Marker('there!'); + const s = new Section('h2', [m1,m2]); -// test: a section can clear a range: -// * truncating the markers on the boundaries -// * removing the intermediate markers -// * connecting (but not joining) the truncated boundary markers + s.splitMarker(m2, 0); + assert.equal(s.markers.length, 2, 'still 2 markers'); + assert.equal(s.markers[0].value, 'hi ', 'original 1st marker unchanged'); + assert.equal(s.markers[1].value, 'there!', 'original 2nd marker unchanged'); +}); diff --git a/tests/unit/renderers/editor-dom-test.js b/tests/unit/renderers/editor-dom-test.js index 0af46e402..28e86741e 100644 --- a/tests/unit/renderers/editor-dom-test.js +++ b/tests/unit/renderers/editor-dom-test.js @@ -393,7 +393,6 @@ test('rerender a marker after removing a markup from it (when both markers have '

text1text2

'); }); - /* test("It renders a renderTree with rendered dirty section", (assert) => { /*