Commit 5ccfd533 authored by Nick Stenning's avatar Nick Stenning

Merge pull request #3017 from hypothesis/editor-markdown-commands-refactor

Refactor and add tests for annotation editor toolbar commands
parents 21f899d1 692ea39a
This diff is collapsed.
......@@ -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;
} else if (pos < newSelectionStart &&
pos + length > newSelectionEnd) {
// 6. Replaced text fully contains selection:
// Expand selection to replaced text
newSelectionStart = pos;
newSelectionEnd = pos + 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;
}
function startOfLine(str, pos) {
var start = str.lastIndexOf('\n', pos);
if (start < 0) {
return 0;
} else {
return start + 1;
}
}
function endOfLine(str, pos) {
var end = str.indexOf('\n', pos);
if (end < 0) {
return str.length;
} else {
return end;
}
}
/**
* Transform lines between two positions in an input field.
*
* @param {EditorState} state - The initial state of the input field
* @param {number} start - The start position within the input text
* @param {number} end - The end position within the input text
* @param {(EditorState, number) => EditorState} callback
* - Callback which is invoked with the current state of the input and
* the start of the current line and returns the new state of the input.
*/
function transformLines(state, start, end, callback) {
var lineStart = startOfLine(state.text, start);
var lineEnd = endOfLine(state.text, start);
while (lineEnd <= endOfLine(state.text, end)) {
var isLastLine = lineEnd === state.text.length;
var currentLineLength = lineEnd - lineStart;
state = callback(state, lineStart, lineEnd);
var newLineLength = endOfLine(state.text, lineStart) - lineStart;
end += newLineLength - currentLineLength;
if (isLastLine) {
break;
}
lineStart = lineStart + newLineLength + 1;
lineEnd = endOfLine(state.text, lineStart);
}
return state;
}
/**
* 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) {
var start = state.selectionStart;
var end = state.selectionEnd;
// Test whether all lines in the selected range already have the style
// applied
var blockHasStyle = true;
transformLines(state, start, end, function (state, lineStart) {
if (state.text.slice(lineStart, lineStart + prefix.length) !== prefix) {
blockHasStyle = false;
}
return state;
});
if (blockHasStyle) {
// Remove the formatting.
return transformLines(state, start, end, function (state, lineStart) {
return replaceText(state, lineStart, prefix.length, '');
});
} else {
// Add the block style to any lines which do not already have it applied
return transformLines(state, start, end, function (state, lineStart) {
if (state.text.slice(lineStart, lineStart + prefix.length) === prefix) {
return state;
} else {
return replaceText(state, lineStart, 0, prefix);
}
});
}
}
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');
});
it('preserves the selection', function () {
var output = toggle(parseState('one <sel>two\nthree </sel>four'));
assert.equal(formatState(output), '> one <sel>two\n> three </sel>four');
});
});
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