Commit 97969022 authored by Robert Knight's avatar Robert Knight

Remove the Angular markdown component

This has been replaced by the `MarkdownView` and `MarkdownEditor`
components.
parent 298518b9
'use strict';
const debounce = require('lodash.debounce');
const commands = require('../markdown-commands');
const mediaEmbedder = require('../media-embedder');
const renderMarkdown = require('../render-markdown');
const scopeTimeout = require('../util/scope-timeout');
// @ngInject
function MarkdownController($element, $scope) {
const input = $element[0].querySelector('.js-markdown-input');
const output = $element[0].querySelector('.js-markdown-preview');
const self = this;
/**
* Transform the editor's input field with an editor command.
*/
function updateState(newStateFn) {
const newState = newStateFn({
text: input.value,
selectionStart: input.selectionStart,
selectionEnd: input.selectionEnd,
});
input.value = newState.text;
input.selectionStart = newState.selectionStart;
input.selectionEnd = newState.selectionEnd;
// 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();
self.onEditText({ text: input.value });
}
function focusInput() {
// When the visibility of the editor changes, focus it.
// A timeout is used so that focus() is not called until
// the visibility change has been applied (by adding or removing
// the relevant CSS classes)
scopeTimeout(
$scope,
function() {
input.focus();
},
0
);
}
this.insertBold = function() {
updateState(function(state) {
return commands.toggleSpanStyle(state, '**', '**', 'Bold');
});
};
this.insertItalic = function() {
updateState(function(state) {
return commands.toggleSpanStyle(state, '*', '*', 'Italic');
});
};
this.insertMath = function() {
updateState(function(state) {
const 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 commands.toggleSpanStyle(state, '\\(', '\\)', 'Insert LaTeX');
}
});
};
this.insertLink = function() {
updateState(function(state) {
return commands.convertSelectionToLink(state);
});
};
this.insertIMG = function() {
updateState(function(state) {
return commands.convertSelectionToLink(
state,
commands.LinkType.IMAGE_LINK
);
});
};
this.insertList = function() {
updateState(function(state) {
return commands.toggleBlockStyle(state, '* ');
});
};
this.insertNumList = function() {
updateState(function(state) {
return commands.toggleBlockStyle(state, '1. ');
});
};
this.insertQuote = function() {
updateState(function(state) {
return commands.toggleBlockStyle(state, '> ');
});
};
// Keyboard shortcuts for bold, italic, and link.
$element.on('keydown', function(e) {
const shortcuts = {
66: self.insertBold,
73: self.insertItalic,
75: self.insertLink,
};
const shortcut = shortcuts[e.keyCode];
if (shortcut && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
shortcut();
}
});
this.preview = false;
this.togglePreview = function() {
self.preview = !self.preview;
};
const handleInputChange = debounce(function() {
$scope.$apply(function() {
self.onEditText({ text: input.value });
});
}, 100);
input.addEventListener('input', handleInputChange);
// Re-render the markdown when the view needs updating.
$scope.$watch('vm.text', function() {
output.innerHTML = renderMarkdown(self.text || '');
mediaEmbedder.replaceLinksWithEmbeds(output);
});
this.showEditor = function() {
return !self.readOnly && !self.preview;
};
// Exit preview mode when leaving edit mode
$scope.$watch('vm.readOnly', function() {
self.preview = false;
});
$scope.$watch('vm.showEditor()', function(show) {
if (show) {
input.value = self.text || '';
focusInput();
}
});
}
/**
* @name markdown
* @description
* This directive controls both the rendering and display of markdown, as well as
* the markdown editor.
*/
// @ngInject
module.exports = {
controller: MarkdownController,
controllerAs: 'vm',
bindings: {
customTextClass: '<?',
readOnly: '<',
text: '<?',
onEditText: '&',
},
template: require('../templates/markdown.html'),
};
'use strict';
const angular = require('angular');
const util = require('../../directive/test/util');
const markdown = require('../markdown');
describe('markdown', function() {
function isHidden(element) {
return element.classList.contains('ng-hide');
}
function inputElement(editor) {
return editor[0].querySelector('.form-input');
}
function viewElement(editor) {
return editor[0].querySelector('.markdown-body');
}
function toolbarButtons(editor) {
return Array.from(editor[0].querySelectorAll('.markdown-tools-button'));
}
function getRenderedHTML(editor) {
const contentElement = viewElement(editor);
if (isHidden(contentElement)) {
return 'rendered markdown is hidden';
}
return contentElement.innerHTML;
}
function mockFormattingCommand() {
return {
text: 'formatted text',
selectionStart: 0,
selectionEnd: 0,
};
}
before(function() {
angular.module('app', []).component('markdown', markdown);
});
beforeEach(function() {
angular.mock.module('app');
markdown.$imports.$mock({
'lodash.debounce': function(fn) {
// Make input change debouncing synchronous in tests
return function() {
fn();
};
},
'../render-markdown': markdown => {
return 'rendered:' + markdown;
},
'../markdown-commands': {
convertSelectionToLink: mockFormattingCommand,
toggleBlockStyle: mockFormattingCommand,
toggleSpanStyle: mockFormattingCommand,
LinkType: require('../../markdown-commands').LinkType,
},
'../media-embedder': {
replaceLinksWithEmbeds: function(element) {
// Tag the element as having been processed
element.dataset.replacedLinksWithEmbeds = 'yes';
},
},
});
});
afterEach(() => {
markdown.$imports.$restore();
});
describe('read only state', function() {
it('should show the rendered view when readOnly is true', function() {
const editor = util.createDirective(document, 'markdown', {
readOnly: true,
text: 'Hello World',
});
assert.isTrue(isHidden(inputElement(editor)));
assert.isFalse(isHidden(viewElement(editor)));
});
it('should show the editor when readOnly is false', function() {
const editor = util.createDirective(document, 'markdown', {
readOnly: false,
text: 'Hello World',
});
assert.isFalse(isHidden(inputElement(editor)));
assert.isTrue(isHidden(viewElement(editor)));
});
});
describe('rendering', function() {
it('should render input markdown', function() {
const editor = util.createDirective(document, 'markdown', {
readOnly: true,
text: 'Hello World',
});
assert.equal(getRenderedHTML(editor), 'rendered:Hello World');
});
it('should render nothing if no text is provided', function() {
const editor = util.createDirective(document, 'markdown', {
readOnly: true,
});
assert.equal(getRenderedHTML(editor), 'rendered:');
});
it('should replace links with embeds in rendered output', function() {
const editor = util.createDirective(document, 'markdown', {
readOnly: true,
text: 'A video: https://www.youtube.com/watch?v=yJDv-zdhzMY',
});
assert.equal(viewElement(editor).dataset.replacedLinksWithEmbeds, 'yes');
});
it('should tolerate malformed HTML', function() {
const editor = util.createDirective(document, 'markdown', {
readOnly: true,
text: 'Hello <one two.',
});
assert.equal(getRenderedHTML(editor), 'rendered:Hello ');
});
});
describe('toolbar buttons', function() {
it('should apply formatting when clicking toolbar buttons', function() {
const editor = util.createDirective(document, 'markdown', {
readOnly: false,
text: 'Hello World',
});
const input = inputElement(editor);
toolbarButtons(editor).forEach(function(button) {
input.value = 'original text';
angular.element(button).click();
assert.equal(input.value, mockFormattingCommand().text);
});
});
it('should notify parent that the text changed', function() {
const onEditText = sinon.stub();
const editor = util.createDirective(document, 'markdown', {
readOnly: false,
text: 'Hello World',
onEditText: {
args: ['text'],
callback: onEditText,
},
});
toolbarButtons(editor).forEach(function(button) {
onEditText.reset();
angular.element(button).click();
assert.calledWith(onEditText, inputElement(editor).value);
});
});
});
describe('editing', function() {
it('should populate the input with the current text', function() {
const editor = util.createDirective(document, 'markdown', {
readOnly: false,
text: 'initial comment',
onEditText: function() {},
});
const input = inputElement(editor);
assert.equal(input.value, 'initial comment');
});
it('should populate the input with empty text if no text is specified', function() {
const editor = util.createDirective(document, 'markdown', {
readOnly: false,
onEditText: function() {},
});
const input = inputElement(editor);
assert.equal(input.value, '');
});
it('should call onEditText() callback when text changes', function() {
const onEditText = sinon.stub();
const editor = util.createDirective(document, 'markdown', {
readOnly: false,
text: 'Hello World',
onEditText: {
args: ['text'],
callback: onEditText,
},
});
const input = inputElement(editor);
input.value = 'new text';
util.sendEvent(input, 'input');
assert.called(onEditText);
assert.calledWith(onEditText, 'new text');
});
});
describe('preview state', function() {
let editor;
function togglePreview() {
const toggle = editor[0].querySelector('.markdown-tools-toggle');
angular.element(toggle).click();
editor.scope.$digest();
}
function isPreviewing() {
return editor.ctrl.preview;
}
beforeEach(function() {
// Create a new editor, initially in editing mode
editor = util.createDirective(document, 'markdown', {
readOnly: false,
text: 'Hello World',
});
});
it('enters preview mode when clicking the "Preview" toggle button', function() {
togglePreview();
assert.isTrue(isPreviewing());
});
it('should hide the input when previewing changes', function() {
togglePreview();
assert.isTrue(isHidden(inputElement(editor)));
});
it('should show the rendered markdown when previewing changes', function() {
togglePreview();
assert.isFalse(isHidden(viewElement(editor)));
});
it('exits preview mode when switching to read-only mode', function() {
togglePreview();
editor.scope.readOnly = true;
editor.scope.$digest();
assert.isFalse(isPreviewing());
});
});
describe('custom text class', function() {
it('should apply custom text class to text container', function() {
const editor = util.createDirective(document, 'markdown', {
customTextClass: 'fancy-effect',
readOnly: true,
});
const viewEl = viewElement(editor);
assert.include(viewEl.className, 'fancy-effect');
});
});
});
<div ng-if="!vm.readOnly" class="markdown-tools" ng-class="vm.preview && 'disable'">
<span class="markdown-preview-toggle">
<a class="markdown-tools-badge h-icon-markdown" href="https://help.github.com/articles/markdown-basics" title="Parsed as Markdown" target="_blank"></a>
<a href="" class="markdown-tools-toggle" ng-click="vm.togglePreview()"
ng-show="!vm.preview">Preview</a>
<a href="" class="markdown-tools-toggle" ng-click="vm.togglePreview()"
ng-show="vm.preview">Write</a>
</span>
<i class="h-icon-format-bold markdown-tools-button" ng-click="vm.insertBold()" title="Embolden text"></i>
<i class="h-icon-format-italic markdown-tools-button" ng-click="vm.insertItalic()" title="Italicize text"></i>
<i class="h-icon-format-quote markdown-tools-button" ng-click="vm.insertQuote()" title="Quote text"></i>
<i class="h-icon-insert-link markdown-tools-button" ng-click="vm.insertLink()" title="Insert link"></i>
<i class="h-icon-insert-photo markdown-tools-button" ng-click="vm.insertIMG()" title="Insert image"></i>
<i class="h-icon-functions markdown-tools-button" ng-click="vm.insertMath()" title="Insert mathematical notation (LaTex is supported)"></i>
<i class="h-icon-format-list-numbered markdown-tools-button" ng-click="vm.insertNumList()" title="Insert numbered list"></i>
<i class="h-icon-format-list-bulleted markdown-tools-button" ng-click="vm.insertList()" title="Insert list"></i>
</div>
<textarea class="form-input form-textarea js-markdown-input"
ng-show="vm.showEditor()"
ng-click="$event.stopPropagation()"
h-branding="annotationFontFamily"></textarea>
<div class="markdown-body js-markdown-preview"
ng-class="(vm.preview && 'markdown-preview') || vm.customTextClass"
ng-dblclick="vm.togglePreview()"
ng-show="!vm.showEditor()"
h-branding="annotationFontFamily"></div>
//MARKDOWN EDITOR //////////////////////////
.markdown-preview {
overflow: auto;
border: 0.1em solid $gray-lighter;
background-color: $gray-lightest;
min-height: 120px;
padding-left: 0.9em;
resize: vertical;
}
.markdown-tools {
background-color: $white;
border-top: 0.1em solid #d3d3d3;
border-left: 0.1em solid #d3d3d3;
border-right: 0.1em solid #d3d3d3;
border-radius: 0.15em 0.15em 0 0;
width: 100%;
margin-bottom: -0.1em;
padding: 0.7em 0.7em 0.7em 0.5em;
user-select: none;
&.disable {
.markdown-tools-button {
color: $gray-lighter;
pointer-events: none;
}
}
.markdown-tools-button {
padding: 0.4em;
}
.markdown-tools-button,
.markdown-tools-toggle,
.markdown-tools-badge {
color: $gray;
&:hover,
&:focus {
color: black;
}
}
.markdown-preview-toggle {
float: right;
}
}
.markdown-body {
@include styled-text;
cursor: text;
// Prevent long URLs etc. in body causing overflow
overflow-wrap: break-word;
// Margin between bottom of ascent of username and top of
// x-height of annotation-body should be ~15px.
// Remove additional margin-top added by the first p within
// the annotation-body
p:first-child {
margin-top: 0;
}
// Margin between bottom of ascent of annotation-body and top of
// ascent of annotation-footer should be ~15px in threaded-replies
// and 20px in the top level annotation.
// Remove additional margin-bottom added by the last p within
// the annotation-body
p:last-child {
margin-bottom: 1px;
}
}
......@@ -32,7 +32,6 @@ $base-line-height: 20px;
@import './components/group-list-item';
@import './components/help-panel';
@import './components/logged-out-message';
@import './components/markdown';
@import './components/markdown-editor';
@import './components/markdown-view';
@import './components/menu';
......
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