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
......@@ -5,6 +5,7 @@
var angular = require('angular');
var katex = require('katex');
var commands = require('../markdown-commands');
var mediaEmbedder = require('../media-embedder');
var loadMathJax = function() {
......@@ -42,286 +43,82 @@ module.exports = function($filter, $sanitize, $sce, $timeout) {
var inputEl = angular.element(input);
var output = elem[0].querySelector('.js-markdown-preview');
var userSelection = function() {
var selection;
if (input.selectionStart !== undefined) {
var startPos = input.selectionStart;
var endPos = input.selectionEnd;
var selectedText = input.value.substring(startPos, endPos);
var textBefore = input.value.substring(0, (startPos));
var textAfter = input.value.substring(endPos);
selection = {
before: textBefore,
after: textAfter,
selection: selectedText,
start: startPos,
end: endPos
};
}
return selection;
};
/**
* Transform the editor's input field with an editor command.
*/
function updateState(newStateFn) {
var newState = newStateFn({
text: input.value,
selectionStart: input.selectionStart,
selectionEnd: input.selectionEnd,
});
var insertMarkup = function(value, selectionStart, selectionEnd) {
// New value is set for the input
input.value = value;
// A new selection is set, or the cursor is positioned inside the input.
input.selectionStart = selectionStart;
input.selectionEnd = selectionEnd;
// Focus the input
return input.focus();
};
input.value = newState.text;
input.selectionStart = newState.selectionStart;
input.selectionEnd = newState.selectionEnd;
var applyInlineMarkup = function(markupL, innertext, markupR) {
if (!markupR) {
markupR = markupL;
// The input field currently loses focus when the contents are
// changed. This re-focuses the input field but really it should
// happen automatically.
input.focus();
}
var newtext;
var end;
var start;
var text = userSelection();
if (text.selection === "") {
newtext = text.before + markupL + innertext + markupR + text.after;
start = (text.before + markupL).length;
end = (text.before + innertext + markupR).length;
return insertMarkup(newtext, start, end);
} else {
// Check to see if markup has already been applied before to the selection.
var slice1 = text.before.slice(text.before.length - markupL.length);
var slice2 = text.after.slice(0, markupR.length);
if (slice1 === markupL && slice2 === markupR) {
// Remove markup
newtext = (
text.before.slice(0, (text.before.length - markupL.length)) +
text.selection + text.after.slice(markupR.length)
);
start = text.before.length - markupL.length;
end = (text.before + text.selection).length - markupR.length;
return insertMarkup(newtext, start, end);
} else {
// Apply markup
newtext = text.before + markupL + text.selection + markupR + text.after;
start = (text.before + markupL).length;
end = (text.before + text.selection + markupR).length;
return insertMarkup(newtext, start, end);
}
}
};
scope.insertBold = function() {
return applyInlineMarkup("**", "Bold");
updateState(function (state) {
return commands.toggleSpanStyle(state, '**', '**', 'Bold');
});
};
scope.insertItalic = function() {
return applyInlineMarkup("*", "Italic");
updateState(function (state) {
return commands.toggleSpanStyle(state, '*', '*', 'Italic');
});
};
scope.insertMath = function() {
var text = userSelection();
var index = text.before.length;
if (
index === 0 ||
input.value[index - 1] === '\n' ||
(input.value[index - 1] === '$' && input.value[index - 2] === '$')
) {
return applyInlineMarkup('$$', 'Insert LaTeX');
updateState(function (state) {
var before = state.text.slice(0, state.selectionStart);
if (before.length === 0 ||
before.slice(-1) === '\n' ||
before.slice(-2) === '$$') {
return commands.toggleSpanStyle(state, '$$', '$$', 'Insert LaTeX');
} else {
return applyInlineMarkup('\\(', 'Insert LaTeX', '\\)');
return commands.toggleSpanStyle(state, '\\(', '\\)',
'Insert LaTeX');
}
});
};
scope.insertLink = function() {
var text = userSelection();
var newtext;
var start;
var end;
if (text.selection === "") {
newtext = text.before + "[Link Text](https://example.com)" + text.after;
start = text.before.length + 1;
end = text.before.length + 10;
return insertMarkup(newtext, start, end);
} else {
// Check to see if markup has already been applied to avoid double presses.
if (text.selection === "Link Text" || text.selection === "https://example.com") {
return;
}
newtext = text.before + '[' + text.selection + '](https://example.com)' + text.after;
start = (text.before + text.selection).length + 3;
end = (text.before + text.selection).length + 22;
return insertMarkup(newtext, start, end);
}
updateState(function (state) {
return commands.convertSelectionToLink(state);
});
};
scope.insertIMG = function() {
var text = userSelection();
var newtext;
var start;
var end;
if (text.selection === "") {
newtext = text.before + "![Image Description](https://yourimage.jpg)" + text.after;
start = text.before.length + 21;
end = text.before.length + 42;
return insertMarkup(newtext, start, end);
} else {
// Check to see if markup has already been applied to avoid double presses.
if (text.selection === "https://yourimage.jpg") {
return;
}
newtext = text.before + '![' + text.selection + '](https://yourimage.jpg)' + text.after;
start = (text.before + text.selection).length + 4;
end = (text.before + text.selection).length + 25;
return insertMarkup(newtext, start, end);
}
};
/* jshint maxcomplexity:false */
scope.applyBlockMarkup = function(markup) {
var text = userSelection();
var ch;
var value;
var start;
var end;
var index;
var i;
var newtext;
if (text.selection !== "") {
var newstring = "";
index = text.before.length;
if (index === 0) {
// The selection takes place at the very start of the input
for (var j = 0; j < text.selection.length; j++) {
ch = text.selection[j];
if (ch === "\n") {
newstring = newstring + "\n" + markup;
} else if (index === 0) {
newstring = newstring + markup + ch;
} else {
newstring = newstring + ch;
}
index += 1;
}
} else {
var newlinedetected = false;
if (input.value.substring(index - 1).charAt(0) === "\n") {
// Look to see if the selection falls at the beginning of a new line.
newstring = newstring + markup;
newlinedetected = true;
}
for (var k = 0; k < text.selection.length; k++) {
ch = text.selection[k];
if (ch === "\n") {
newstring = newstring + "\n" + markup;
newlinedetected = true;
} else {
newstring = newstring + ch;
}
index += 1;
}
if (!newlinedetected) {
// Edge case: The selection does not include any new lines and does not start at 0.
// We need to find the newline before the currently selected text and add markup there.
i = 0;
var indexoflastnewline;
newstring = "";
var iterable = text.before + text.selection;
for (var i1 = 0; i1 < iterable.length; i1++) {
ch = iterable[i1];
if (ch === "\n") {
indexoflastnewline = i;
}
newstring = newstring + ch;
i++;
}
if (indexoflastnewline === undefined) {
// The partial selection happens to fall on the firstline
newstring = markup + newstring;
} else {
newstring = (
newstring.substring(0, (indexoflastnewline + 1)) +
markup + newstring.substring(indexoflastnewline + 1)
);
}
value = newstring + text.after;
start = (text.before + markup).length;
end = (text.before + text.selection + markup).length;
insertMarkup(value, start, end);
return;
}
}
// Sets input value and selection for cases where there are new lines in the selection
// or the selection is at the start
value = text.before + newstring + text.after;
start = (text.before + newstring).length;
end = (text.before + newstring).length;
return insertMarkup(value, start, end);
} else if (input.value.substring((text.start - 1 ), text.start) === "\n") {
// Edge case, no selection, the cursor is on a new line.
value = text.before + markup + text.selection + text.after;
start = (text.before + markup).length;
end = (text.before + markup).length;
return insertMarkup(value, start, end);
} else {
// No selection, cursor is not on new line.
// Check to see if markup has already been inserted.
if (text.before.slice(text.before.length - markup.length) === markup) {
newtext = (
text.before.substring(0, (index)) + "\n" +
text.before.substring(index + 1 + markup.length) + text.after
);
}
i = 0;
for (var i2 = 0, char; i2 < text.before.length; i2++) {
char = text.before[i2];
if (char === "\n" && i !== 0) {
index = i;
}
i += 1;
}
if (!index) { // If the line of text happens to fall on the first line and index is not set.
// Check to see if markup has already been inserted and undo it.
if (text.before.slice(0, markup.length) === markup) {
newtext = text.before.substring(markup.length) + text.after;
start = text.before.length - markup.length;
end = text.before.length - markup.length;
return insertMarkup(newtext, start, end);
} else {
newtext = markup + text.before.substring(0) + text.after;
start = (text.before + markup).length;
end = (text.before + markup).length;
return insertMarkup(newtext, start, end);
}
// Check to see if markup has already been inserted and undo it.
} else if (text.before.slice((index + 1), (index + 1 + markup.length)) === markup) {
newtext = (
text.before.substring(0, (index)) + "\n" +
text.before.substring(index + 1 + markup.length) + text.after
);
start = text.before.length - markup.length;
end = text.before.length - markup.length;
return insertMarkup(newtext, start, end);
} else {
newtext = (
text.before.substring(0, (index)) + "\n" +
markup + text.before.substring(index + 1) + text.after
);
start = (text.before + markup).length;
end = (text.before + markup).length;
return insertMarkup(newtext, start, end);
}
}
updateState(function (state) {
return commands.convertSelectionToLink(state,
commands.LinkType.IMAGE_LINK);
});
};
scope.insertList = function() {
return scope.applyBlockMarkup("* ");
updateState(function (state) {
return commands.toggleBlockStyle(state, '* ');
});
};
scope.insertNumList = function() {
return scope.applyBlockMarkup("1. ");
updateState(function (state) {
return commands.toggleBlockStyle(state, '1. ');
});
};
scope.insertQuote = function() {
return scope.applyBlockMarkup("> ");
updateState(function (state) {
return commands.toggleBlockStyle(state, '> ');
});
};
// Keyboard shortcuts for bold, italic, and link.
......@@ -449,7 +246,7 @@ module.exports = function($filter, $sanitize, $sce, $timeout) {
// Re-render the markdown when the view needs updating.
ctrl.$render = function() {
if (!scope.readOnly && !scope.preview) {
inputEl.val((ctrl.$viewValue || ''));
input.value = ctrl.$viewValue || '';
}
var value = ctrl.$viewValue || '';
output.innerHTML = renderMathAndMarkdown(value);
......@@ -462,12 +259,10 @@ module.exports = function($filter, $sanitize, $sce, $timeout) {
// React to the changes to the input
inputEl.bind('blur change keyup', function() {
return $timeout(function() {
return ctrl.$setViewValue(inputEl.val());
});
ctrl.$setViewValue(input.value);
});
// Reset height of output div incase it has been changed.
// Reset height of output div in case it has been changed.
// Re-render when it becomes uneditable.
// Auto-focus the input box when the widget becomes editable.
return scope.$watch('readOnly', function(readOnly) {
......@@ -475,7 +270,7 @@ module.exports = function($filter, $sanitize, $sce, $timeout) {
output.style.height = "";
ctrl.$render();
if (!readOnly) {
return $timeout(function() { return input.focus(); });
input.focus();
}
});
},
......@@ -483,7 +278,7 @@ module.exports = function($filter, $sanitize, $sce, $timeout) {
require: '?ngModel',
restrict: 'E',
scope: {
readOnly: '=',
readOnly: '<',
required: '@'
},
templateUrl: 'markdown.html'
......
......@@ -39,6 +39,14 @@ describe('markdown', function () {
return contentElement.innerHTML;
}
function mockFormattingCommand() {
return {
text: 'formatted text',
selectionStart: 0,
selectionEnd: 0,
};
}
before(function () {
angular.module('app', ['ngSanitize'])
.directive('markdown', proxyquire('../markdown', {
......@@ -48,6 +56,12 @@ describe('markdown', function () {
return 'math:' + input.replace(/$$/g, '');
},
},
'../markdown-commands': {
convertSelectionToLink: mockFormattingCommand,
toggleBlockStyle: mockFormattingCommand,
toggleSpanStyle: mockFormattingCommand,
LinkType: require('../../markdown-commands').LinkType,
},
'@noCallThru': true,
}))
.filter('converter', function () {
......@@ -102,4 +116,33 @@ describe('markdown', function () {
'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';
/* global angular */
/**
* Converts a camelCase name into hyphenated ('camel-case') form.
*
......@@ -101,7 +103,7 @@ function createDirective(document, name, attrs, initialScope, initialHtml, opts)
angular.mock.inject(function (_$compile_, _$rootScope_) {
$compile = _$compile_;
$scope = _$rootScope_.$new();
})
});
var templateElement = document.createElement(hyphenate(name));
Object.keys(attrs).forEach(function (key) {
var attrName = hyphenate(key);
......@@ -145,12 +147,22 @@ function createDirective(document, name, attrs, initialScope, initialHtml, opts)
childScope.$digest();
element.ctrl = element.controller(name);
return element;
}
};
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 = {
createDirective: createDirective,
ngModule: ngModule,
sendEvent: sendEvent,
};
'use strict';
// Minimal set of polyfills for PhantomJS 1.x under Karma.
// 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