Commit c069f232 authored by Robert Knight's avatar Robert Knight

Refactor markdown command insertion

 * Separate the logic for transforming the input field
   from the logic for actually reading the state of
   the input field and applying changes.

   This enables testing of the commands without
   instantiating the component.

   Toolbar commands are functions which take
   the current state of the input field and return
   the updated state.

 * Add tests for the individual commands and use
   of the commands in the markdown editor.
parent fe9b1ba8
This diff is collapsed.
...@@ -39,6 +39,14 @@ describe('markdown', function () { ...@@ -39,6 +39,14 @@ describe('markdown', function () {
return contentElement.innerHTML; return contentElement.innerHTML;
} }
function mockFormattingCommand() {
return {
text: 'formatted text',
selectionStart: 0,
selectionEnd: 0,
};
}
before(function () { before(function () {
angular.module('app', ['ngSanitize']) angular.module('app', ['ngSanitize'])
.directive('markdown', proxyquire('../markdown', { .directive('markdown', proxyquire('../markdown', {
...@@ -48,6 +56,12 @@ describe('markdown', function () { ...@@ -48,6 +56,12 @@ describe('markdown', function () {
return 'math:' + input.replace(/$$/g, ''); return 'math:' + input.replace(/$$/g, '');
}, },
}, },
'../markdown-commands': {
convertSelectionToLink: mockFormattingCommand,
toggleBlockStyle: mockFormattingCommand,
toggleSpanStyle: mockFormattingCommand,
LinkType: require('../../markdown-commands').LinkType,
},
'@noCallThru': true, '@noCallThru': true,
})) }))
.filter('converter', function () { .filter('converter', function () {
...@@ -102,4 +116,33 @@ describe('markdown', function () { ...@@ -102,4 +116,33 @@ describe('markdown', function () {
'rendered:math:\\displaystyle {x*2}rendered:'); 'rendered:math:\\displaystyle {x*2}rendered:');
}); });
}); });
describe('toolbar buttons', function () {
it('should apply formatting when clicking toolbar buttons', function () {
var editor = util.createDirective(document, 'markdown', {
readOnly: false,
ngModel: 'Hello World',
});
var input = inputElement(editor);
var buttons = editor[0].querySelectorAll('.markdown-tools-button');
for (var i=0; i < buttons.length; i++) {
input.value = 'original text';
angular.element(buttons[i]).click();
assert.equal(input.value, mockFormattingCommand().text);
}
});
});
describe('editing', function () {
it('should update the input model', function () {
var editor = util.createDirective(document, 'markdown', {
readOnly: false,
ngModel: 'Hello World',
});
var input = inputElement(editor);
input.value = 'new text';
util.sendEvent(input, 'change');
assert.equal(editor.scope.ngModel, 'new text');
});
});
}); });
'use strict'; 'use strict';
/* global angular */
/** /**
* Converts a camelCase name into hyphenated ('camel-case') form. * Converts a camelCase name into hyphenated ('camel-case') form.
* *
...@@ -101,7 +103,7 @@ function createDirective(document, name, attrs, initialScope, initialHtml, opts) ...@@ -101,7 +103,7 @@ function createDirective(document, name, attrs, initialScope, initialHtml, opts)
angular.mock.inject(function (_$compile_, _$rootScope_) { angular.mock.inject(function (_$compile_, _$rootScope_) {
$compile = _$compile_; $compile = _$compile_;
$scope = _$rootScope_.$new(); $scope = _$rootScope_.$new();
}) });
var templateElement = document.createElement(hyphenate(name)); var templateElement = document.createElement(hyphenate(name));
Object.keys(attrs).forEach(function (key) { Object.keys(attrs).forEach(function (key) {
var attrName = hyphenate(key); var attrName = hyphenate(key);
...@@ -145,12 +147,22 @@ function createDirective(document, name, attrs, initialScope, initialHtml, opts) ...@@ -145,12 +147,22 @@ function createDirective(document, name, attrs, initialScope, initialHtml, opts)
childScope.$digest(); childScope.$digest();
element.ctrl = element.controller(name); element.ctrl = element.controller(name);
return element; return element;
} };
return linkDirective(initialScope); return linkDirective(initialScope);
} }
/** Helper to dispatch a native event to a DOM element. */
function sendEvent(element, eventType) {
// createEvent() used instead of Event constructor
// for PhantomJS compatibility
var event = document.createEvent('Event');
event.initEvent(eventType, true /* bubbles */, true /* cancelable */);
element.dispatchEvent(event);
}
module.exports = { module.exports = {
createDirective: createDirective, createDirective: createDirective,
ngModule: ngModule, ngModule: ngModule,
sendEvent: sendEvent,
}; };
'use strict';
// Minimal set of polyfills for PhantomJS 1.x under Karma. // Minimal set of polyfills for PhantomJS 1.x under Karma.
// this Polyfills: // this Polyfills:
// //
......
'use strict';
/**
* Commands for toggling markdown formatting of a selection
* in an input field.
*
* All of the functions in this module take as input the current state
* of the input field, parameters for the operation to perform and return the
* new state of the input field.
*/
/**
* Describes the state of a plain text input field.
*
* interface EditorState {
* text: string;
* selectionStart: number;
* selectionEnd: number;
* }
*/
/**
* Types of Markdown link that can be inserted with
* convertSelectionToLink()
*/
var LinkType = {
ANCHOR_LINK: 0,
IMAGE_LINK: 1,
};
/**
* Replace text in an input field and return the new state.
*
* @param {EditorState} state - The state of the input field.
* @param {number} pos - The start position of the text to remove.
* @param {number} length - The number of characters to remove.
* @param {string} text - The replacement text to insert at @p pos.
* @return {EditorState} - The new state of the input field.
*/
function replaceText(state, pos, length, text) {
var newSelectionStart = state.selectionStart;
var newSelectionEnd = state.selectionEnd;
if (newSelectionEnd <= pos) {
// 1. Selection is before replaced text: Leave selection unchanged
} else if (newSelectionStart >= pos + length) {
// 2. Selection is after replaced text:
// Increment (start, end) by difference in length between original and
// replaced text
newSelectionStart += text.length - length;
newSelectionEnd += text.length - length;
} else if (newSelectionStart <= pos &&
newSelectionEnd >= pos + length) {
// 3. Selection fully contains replaced text:
// Increment end by difference in length between original and replaced
// text
newSelectionEnd += text.length - length;
} else if (newSelectionStart < pos &&
newSelectionEnd < pos + length) {
// 4. Selection overlaps start but not end of replaced text:
// Decrement start to start of replacement text
newSelectionStart = pos;
} else if (newSelectionStart < pos + length &&
newSelectionEnd > pos + length) {
// 5. Selection overlaps end but not start of replaced text:
// Increment end by difference in length between original and replaced
// text
newSelectionEnd += text.length - length;
}
return {
text: state.text.slice(0, pos) + text + state.text.slice(pos + length),
selectionStart: newSelectionStart,
selectionEnd: newSelectionEnd,
};
}
/**
* Convert the selected text into a Markdown link.
*
* @param {EditorState} state - The current state of the input field.
* @param {LinkType} linkType - The type of link to insert.
* @return {EditorState} - The new state of the input field.
*/
function convertSelectionToLink(state, linkType) {
if (typeof linkType === 'undefined') {
linkType = LinkType.ANCHOR_LINK;
}
var selection = state.text.slice(state.selectionStart, state.selectionEnd);
var linkPrefix = '';
if (linkType === LinkType.IMAGE_LINK) {
linkPrefix = '!';
}
var newState;
if (selection.match(/[a-z]+:.*/)) {
// Selection is a URL, wrap it with a link and use the selection as
// the target.
var dummyLabel = 'Description';
newState = replaceText(state, state.selectionStart, selection.length,
linkPrefix + '[' + dummyLabel + '](' + selection + ')');
newState.selectionStart = state.selectionStart + linkPrefix.length + 1;
newState.selectionEnd = newState.selectionStart + dummyLabel.length;
return newState;
} else {
// Selection is not a URL, wrap it with a link and use the selection as
// the label. Change the selection to the dummy link.
var beforeURL = linkPrefix + '[' + selection + '](';
var dummyLink = 'http://insert-your-link-here.com';
newState = replaceText(state, state.selectionStart, selection.length,
beforeURL + dummyLink + ')');
newState.selectionStart = state.selectionStart + beforeURL.length;
newState.selectionEnd = newState.selectionStart + dummyLink.length;
return newState;
}
}
/**
* Toggle Markdown-style formatting around a span of text.
*
* @param {EditorState} state - The current state of the input field.
* @param {string} prefix - The prefix to add or remove
* before the selection.
* @param {string?} suffix - The suffix to add or remove after the selection,
* defaults to being the same as the prefix.
* @param {string} placeholder - The text to insert between 'prefix' and
* 'suffix' if the input text is empty.
* @return {EditorState} The new state of the input field.
*/
function toggleSpanStyle(state, prefix, suffix, placeholder) {
if (typeof suffix === 'undefined') {
suffix = prefix;
}
var selectionPrefix = state.text.slice(state.selectionStart - prefix.length,
state.selectionStart);
var selectionSuffix = state.text.slice(state.selectionEnd,
state.selectionEnd + prefix.length);
var newState = state;
if (state.selectionStart === state.selectionEnd && placeholder) {
newState = replaceText(state, state.selectionStart, 0, placeholder);
newState.selectionEnd = newState.selectionStart + placeholder.length;
}
if (selectionPrefix === prefix && selectionSuffix === suffix) {
newState = replaceText(newState, newState.selectionStart - prefix.length,
prefix.length, '');
newState = replaceText(newState, newState.selectionEnd, suffix.length, '');
} else {
newState = replaceText(newState, newState.selectionStart, 0, prefix);
newState = replaceText(newState, newState.selectionEnd, 0, suffix);
}
return newState;
}
/**
* Toggle Markdown-style formatting around a block of text.
*
* @param {EditorState} state - The current state of the input field.
* @param {string} prefix - The prefix to add or remove before each line
* of the selection.
* @return {EditorState} - The new state of the input field.
*/
function toggleBlockStyle(state, prefix) {
// Expand the start and end of the selection to the start and
// and of their respective lines
var start = state.text.lastIndexOf('\n', state.selectionStart);
if (start < 0) {
start = 0;
} else {
start += 1;
}
var end = state.text.indexOf('\n', state.selectionEnd);
if (end < 0) {
end = state.text.length;
}
// Test whether all input lines are already formatted with this style
var lines = state.text.slice(start, end).split('\n');
var prefixedLines = lines.filter(function (line) {
return line.slice(0, prefix.length) === prefix;
});
var newLines;
if (prefixedLines.length === lines.length) {
// All lines already start with the block prefix, remove the formatting.
newLines = lines.map(function (line) {
return line.slice(prefix.length);
});
} else {
// Add the block style to any lines which do not already have it applied
newLines = lines.map(function (line) {
if (line.slice(0, prefix.length) === prefix) {
return line;
} else {
return prefix + line;
}
});
}
return replaceText(state, start, end - start, newLines.join('\n'));
}
module.exports = {
toggleSpanStyle: toggleSpanStyle,
toggleBlockStyle: toggleBlockStyle,
convertSelectionToLink: convertSelectionToLink,
LinkType: LinkType,
};
'use strict';
var commands = require('../markdown-commands');
/**
* Convert a string containing '<sel>' and '</sel>' markers
* to a commands.EditorState.
*/
function parseState(text) {
var startMarker = '<sel>';
var endMarker = '</sel>';
var selStart = text.indexOf(startMarker);
var selEnd = text.indexOf(endMarker);
if (selStart < 0) {
throw new Error('Input field does not contain a selection start');
}
if (selEnd < 0) {
throw new Error('Input field does not contain a selection end');
}
return {
text: text.replace(/<\/?sel>/g, ''),
selectionStart: selStart,
selectionEnd: selEnd - startMarker.length,
};
}
/**
* Convert a commands.EditorState to a string containing '<sel>'
* and '</sel>' markers.
*/
function formatState(state) {
var selectionStart = state.selectionStart;
var selectionEnd = state.selectionEnd;
var text = state.text;
return text.slice(0, selectionStart) + '<sel>' +
text.slice(selectionStart, selectionEnd) + '</sel>' +
text.slice(selectionEnd);
}
describe('markdown commands', function () {
describe('span formatting', function () {
function toggle(state, prefix, suffix, placeholder) {
prefix = prefix || '**';
suffix = suffix || '**';
return commands.toggleSpanStyle(state, prefix, suffix, placeholder);
}
it('adds formatting to spans', function () {
var output = toggle(parseState('make <sel>text</sel> bold'));
assert.equal(formatState(output), 'make **<sel>text</sel>** bold');
});
it('removes formatting from spans', function () {
var output = toggle(parseState('make **<sel>text</sel>** bold'));
assert.equal(formatState(output), 'make <sel>text</sel> bold');
});
it('adds formatting to spans when the prefix and suffix differ', function () {
var output = toggle(parseState('make <sel>math</sel> mathy'), '\\(',
'\\)');
assert.equal(formatState(output), 'make \\(<sel>math</sel>\\) mathy');
});
it('inserts placeholders if the selection is empty', function () {
var output = toggle(parseState('make <sel></sel> bold'), '**',
undefined, 'Bold');
assert.equal(formatState(output), 'make **<sel>Bold</sel>** bold');
});
});
describe('block formatting', function () {
function toggle(state) {
return commands.toggleBlockStyle(state, '> ');
}
it('adds formatting to blocks', function () {
var output = toggle(parseState('one\n<sel>two\nthree</sel>\nfour'));
assert.equal(formatState(output), 'one\n<sel>> two\n> three</sel>\nfour');
});
it('removes formatting from blocks', function () {
var output = toggle(parseState('one \n<sel>> two\n> three</sel>\nfour'));
assert.equal(formatState(output), 'one \n<sel>two\nthree</sel>\nfour');
});
});
describe('link formatting', function () {
var linkify = function (text, linkType) {
return commands.convertSelectionToLink(parseState(text), linkType);
};
it('converts text to links', function () {
var output = linkify('one <sel>two</sel> three');
assert.equal(formatState(output),
'one [two](<sel>http://insert-your-link-here.com</sel>) three');
});
it('converts URLs to links', function () {
var output = linkify('one <sel>http://foobar.com</sel> three');
assert.equal(formatState(output),
'one [<sel>Description</sel>](http://foobar.com) three');
});
it('converts URLs to image links', function () {
var output = linkify('one <sel>http://foobar.com</sel> three',
commands.LinkType.IMAGE_LINK);
assert.equal(formatState(output),
'one ![<sel>Description</sel>](http://foobar.com) three');
});
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment