Unverified Commit abaf9cf2 authored by Robert Knight's avatar Robert Knight Committed by GitHub

Merge pull request #1439 from hypothesis/convert-markdown-editor

Convert markdown editor 3/3 - Convert editor and improve toolbar UX
parents 4a03cd99 269883ff
'use strict';
const classnames = require('classnames');
const { createElement } = require('preact');
const { useEffect, useRef, useState } = require('preact/hooks');
const propTypes = require('prop-types');
const MarkdownView = require('./markdown-view');
const {
LinkType,
convertSelectionToLink,
toggleBlockStyle,
toggleSpanStyle,
} = require('../markdown-commands');
// Mapping of toolbar command name to key for Ctrl+<key> keyboard shortcuts.
// The shortcuts are taken from Stack Overflow's editor.
const SHORTCUT_KEYS = {
bold: 'b',
italic: 'i',
link: 'l',
quote: 'q',
image: 'g',
numlist: 'o',
list: 'u',
};
/**
* Apply a toolbar command to an editor input field.
*
* @param {string} command
* @param {HTMLInputElement} inputEl
*/
function handleToolbarCommand(command, inputEl) {
const update = newStateFn => {
// Apply the toolbar command to the current state of the input field.
const newState = newStateFn({
text: inputEl.value,
selectionStart: inputEl.selectionStart,
selectionEnd: inputEl.selectionEnd,
});
// Update the input field to match the new state.
inputEl.value = newState.text;
inputEl.selectionStart = newState.selectionStart;
inputEl.selectionEnd = newState.selectionEnd;
// Restore input field focus which is lost when its contents are changed.
inputEl.focus();
};
const insertMath = state => {
const before = state.text.slice(0, state.selectionStart);
if (
before.length === 0 ||
before.slice(-1) === '\n' ||
before.slice(-2) === '$$'
) {
return toggleSpanStyle(state, '$$', '$$', 'Insert LaTeX');
} else {
return toggleSpanStyle(state, '\\(', '\\)', 'Insert LaTeX');
}
};
switch (command) {
case 'bold':
update(state => toggleSpanStyle(state, '**', '**', 'Bold'));
break;
case 'italic':
update(state => toggleSpanStyle(state, '*', '*', 'Italic'));
break;
case 'quote':
update(state => toggleBlockStyle(state, '> '));
break;
case 'link':
update(state => convertSelectionToLink(state));
break;
case 'image':
update(state => convertSelectionToLink(state, LinkType.IMAGE_LINK));
break;
case 'math':
update(insertMath);
break;
case 'numlist':
update(state => toggleBlockStyle(state, '1. '));
break;
case 'list':
update(state => toggleBlockStyle(state, '* '));
break;
default:
throw new Error(`Unknown toolbar command "${command}"`);
}
}
function ToolbarButton({
disabled = false,
icon,
label = null,
onClick,
shortcutKey,
title,
}) {
let tooltip = title;
if (shortcutKey) {
tooltip += ` (Ctrl+${shortcutKey.toUpperCase()})`;
}
return (
<button
className={classnames(
'markdown-editor__toolbar-button',
icon && `h-icon-${icon}`,
label && 'is-text'
)}
disabled={disabled}
onClick={onClick}
title={tooltip}
>
{label}
</button>
);
}
ToolbarButton.propTypes = {
disabled: propTypes.bool,
icon: propTypes.string,
label: propTypes.string,
onClick: propTypes.func,
shortcutKey: propTypes.string,
title: propTypes.string,
};
function Toolbar({ isPreviewing, onCommand, onTogglePreview }) {
return (
<div
className="markdown-editor__toolbar"
role="toolbar"
aria-label="Markdown editor toolbar"
>
<ToolbarButton
disabled={isPreviewing}
icon="format-bold"
onClick={() => onCommand('bold')}
shortcutKey={SHORTCUT_KEYS.bold}
title="Bold"
/>
<ToolbarButton
disabled={isPreviewing}
icon="format-italic"
onClick={() => onCommand('italic')}
shortcutKey={SHORTCUT_KEYS.italic}
title="Italic"
/>
<ToolbarButton
disabled={isPreviewing}
icon="format-quote"
onClick={() => onCommand('quote')}
shortcutKey={SHORTCUT_KEYS.quote}
title="Quote"
/>
<ToolbarButton
disabled={isPreviewing}
icon="link"
onClick={() => onCommand('link')}
shortcutKey={SHORTCUT_KEYS.link}
title="Insert link"
/>
<ToolbarButton
disabled={isPreviewing}
icon="insert-photo"
onClick={() => onCommand('image')}
shortcutKey={SHORTCUT_KEYS.image}
title="Insert image"
/>
<ToolbarButton
disabled={isPreviewing}
icon="functions"
onClick={() => onCommand('math')}
title="Insert math (LaTeX is supported)"
/>
<ToolbarButton
disabled={isPreviewing}
icon="format-list-numbered"
onClick={() => onCommand('numlist')}
shortcutKey={SHORTCUT_KEYS.numlist}
title="Numbered list"
/>
<ToolbarButton
disabled={isPreviewing}
icon="format-list-bulleted"
onClick={() => onCommand('list')}
shortcutKey={SHORTCUT_KEYS.list}
title="Bulleted list"
/>
<span className="u-stretch" />
<ToolbarButton
label={isPreviewing ? 'Write' : 'Preview'}
onClick={onTogglePreview}
/>
</div>
);
}
Toolbar.propTypes = {
/** `true` if the editor's "Preview" mode is active. */
isPreviewing: propTypes.bool,
/** Callback invoked with the selected command when a toolbar button is clicked. */
onCommand: propTypes.func,
/** Callback invoked when the "Preview" toggle button is clicked. */
onTogglePreview: propTypes.func,
};
/**
* Viewer/editor for the body of an annotation in markdown format.
*/
function MarkdownEditor({ onEditText = () => {}, text = '' }) {
/** Whether the preview mode is currently active. */
const [preview, setPreview] = useState(false);
/** The input element where the user inputs their comment. */
const input = useRef(null);
useEffect(() => {
if (!preview) {
input.current.focus();
}
}, [preview]);
const togglePreview = () => setPreview(!preview);
const handleCommand = command => {
const inputEl = input.current;
handleToolbarCommand(command, inputEl);
onEditText({ text: inputEl.value });
};
const handleKeyDown = event => {
if (!event.ctrlKey) {
return;
}
for (let [command, key] of Object.entries(SHORTCUT_KEYS)) {
if (key === event.key) {
event.stopPropagation();
event.preventDefault();
handleCommand(command);
}
}
};
return (
<div>
<Toolbar
onCommand={handleCommand}
isPreviewing={preview}
onTogglePreview={togglePreview}
/>
{preview ? (
<MarkdownView
textClass={{ 'markdown-editor__preview': true }}
markdown={text}
/>
) : (
<textarea
className="form-input form-textarea"
ref={input}
onClick={e => e.stopPropagation()}
onKeydown={handleKeyDown}
onInput={e => onEditText({ text: e.target.value })}
value={text}
/>
)}
</div>
);
}
MarkdownEditor.propTypes = {
/** The markdown text to edit. */
text: propTypes.string,
/**
* Callback invoked with `{ text }` object when user edits text.
*
* TODO: Simplify this callback to take just a string rather than an object
* once the parent component is converted to Preact.
*/
onEditText: propTypes.func,
};
module.exports = MarkdownEditor;
'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'),
};
...@@ -158,8 +158,11 @@ describe('annotation', function() { ...@@ -158,8 +158,11 @@ describe('annotation', function() {
onClick: '&', onClick: '&',
}, },
}) })
.component('markdown', { .component('markdownEditor', {
bindings: require('../markdown').bindings, bindings: {
text: '<',
onEditText: '&',
},
}) })
.component('markdownView', { .component('markdownView', {
bindings: { bindings: {
......
'use strict';
const { createElement, render } = require('preact');
const { act } = require('preact/test-utils');
const { mount } = require('enzyme');
const { LinkType } = require('../../markdown-commands');
const MarkdownEditor = require('../markdown-editor');
describe('MarkdownEditor', () => {
const formatResult = {
text: 'formatted text',
selectionStart: 0,
selectionEnd: 0,
};
const fakeMarkdownCommands = {
convertSelectionToLink: sinon.stub().returns(formatResult),
toggleBlockStyle: sinon.stub().returns(formatResult),
toggleSpanStyle: sinon.stub().returns(formatResult),
LinkType,
};
let MarkdownView;
beforeEach(() => {
fakeMarkdownCommands.convertSelectionToLink.resetHistory();
fakeMarkdownCommands.toggleBlockStyle.resetHistory();
fakeMarkdownCommands.toggleSpanStyle.resetHistory();
MarkdownView = function MarkdownView() {
return null;
};
MarkdownEditor.$imports.$mock({
'../markdown-commands': fakeMarkdownCommands,
'./markdown-view': MarkdownView,
});
});
afterEach(() => {
MarkdownEditor.$imports.$restore();
});
const commands = [
{
command: 'Bold',
key: 'b',
effect: [fakeMarkdownCommands.toggleSpanStyle, '**', '**', 'Bold'],
},
{
command: 'Italic',
key: 'i',
effect: [fakeMarkdownCommands.toggleSpanStyle, '*', '*', 'Italic'],
},
{
command: 'Quote',
key: 'q',
effect: [fakeMarkdownCommands.toggleBlockStyle, '> '],
},
{
command: 'Insert link',
key: 'l',
effect: [fakeMarkdownCommands.convertSelectionToLink],
},
{
command: 'Insert image',
key: null,
effect: [
fakeMarkdownCommands.convertSelectionToLink,
fakeMarkdownCommands.LinkType.IMAGE_LINK,
],
},
{
command: 'Insert math (LaTeX is supported)',
key: null,
effect: [
fakeMarkdownCommands.toggleSpanStyle,
'$$',
'$$',
'Insert LaTeX',
],
},
{
command: 'Bulleted list',
key: 'u',
effect: [fakeMarkdownCommands.toggleBlockStyle, '* '],
},
{
command: 'Numbered list',
key: 'o',
effect: [fakeMarkdownCommands.toggleBlockStyle, '1. '],
},
];
commands.forEach(({ command, key, effect }) => {
describe(`"${command}" toolbar command`, () => {
it('applies formatting when toolbar button is clicked', () => {
const onEditText = sinon.stub();
const wrapper = mount(
<MarkdownEditor text="test" onEditText={onEditText} />
);
const button = wrapper.find(
`ToolbarButton[title="${command}"] > button`
);
const input = wrapper.find('textarea').getDOMNode();
input.selectionStart = 0;
input.selectionEnd = 4;
button.simulate('click');
assert.calledWith(onEditText, {
text: 'formatted text',
});
const [formatFunction, ...args] = effect;
assert.calledWith(
formatFunction,
sinon.match({ text: 'test', selectionStart: 0, selectionEnd: 4 }),
...args
);
});
if (key) {
it('applies formatting when shortcut key is pressed', () => {
const onEditText = sinon.stub();
const wrapper = mount(
<MarkdownEditor text="test" onEditText={onEditText} />
);
const input = wrapper.find('textarea');
input.getDOMNode().selectionStart = 0;
input.getDOMNode().selectionEnd = 4;
input.simulate('keydown', {
ctrlKey: true,
key,
});
assert.calledWith(onEditText, {
text: 'formatted text',
});
const [formatFunction, ...args] = effect;
assert.calledWith(
formatFunction,
sinon.match({ text: 'test', selectionStart: 0, selectionEnd: 4 }),
...args
);
});
}
});
});
[
{
// Shortcut letter but without ctrl key.
key: 'b',
ctrlKey: false,
},
{
// Ctrl key with non-shortcut letter
key: 'w',
ctrlKey: true,
},
].forEach(({ ctrlKey, key }) => {
it('does not apply formatting when a non-shortcut key is pressed', () => {
const onEditText = sinon.stub();
const wrapper = mount(
<MarkdownEditor text="test" onEditText={onEditText} />
);
const input = wrapper.find('textarea');
input.simulate('keydown', {
ctrlKey,
key,
});
assert.notCalled(onEditText);
});
});
it('calls `onEditText` callback when text is changed', () => {
const onEditText = sinon.stub();
const wrapper = mount(
<MarkdownEditor text="test" onEditText={onEditText} />
);
const input = wrapper.find('textarea').getDOMNode();
input.value = 'changed';
wrapper.find('textarea').simulate('input');
assert.calledWith(onEditText, {
text: 'changed',
});
});
it('enters preview mode when Preview button is clicked', () => {
const wrapper = mount(<MarkdownEditor text="test" />);
const previewButton = wrapper
.find('button')
.filterWhere(el => el.text() === 'Preview');
previewButton.simulate('click');
assert.isFalse(wrapper.find('textarea').exists());
assert.isTrue(wrapper.find('MarkdownView').exists());
wrapper
.find('button')
.filterWhere(el => el.text() !== 'Write')
.forEach(el => assert.isTrue(el.prop('disabled')));
});
it('exits preview mode when Write button is clicked', () => {
const wrapper = mount(<MarkdownEditor text="test" />);
// Switch to "Preview" mode.
const previewButton = wrapper
.find('button')
.filterWhere(el => el.text() === 'Preview');
previewButton.simulate('click');
// Switch back to "Write" mode.
const writeButton = wrapper
.find('button')
.filterWhere(el => el.text() === 'Write');
writeButton.simulate('click');
assert.isTrue(wrapper.find('textarea').exists());
assert.isFalse(wrapper.find('MarkdownView').exists());
wrapper
.find('button')
.filterWhere(el => el.text() !== 'Preview')
.forEach(el => assert.isFalse(el.prop('disabled')));
});
it('focuses the input field when created', () => {
const container = document.createElement('div');
try {
document.body.focus();
document.body.appendChild(container);
act(() => {
render(<MarkdownEditor text="test" />, container);
});
assert.equal(document.activeElement.nodeName, 'TEXTAREA');
} finally {
container.remove();
}
});
});
'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');
});
});
});
...@@ -161,7 +161,10 @@ function startAngularApp(config) { ...@@ -161,7 +161,10 @@ function startAngularApp(config) {
'loggedOutMessage', 'loggedOutMessage',
wrapReactComponent(require('./components/logged-out-message')) wrapReactComponent(require('./components/logged-out-message'))
) )
.component('markdown', require('./components/markdown')) .component(
'markdownEditor',
wrapReactComponent(require('./components/markdown-editor'))
)
.component( .component(
'markdownView', 'markdownView',
wrapReactComponent(require('./components/markdown-view')) wrapReactComponent(require('./components/markdown-view'))
......
...@@ -45,11 +45,11 @@ ...@@ -45,11 +45,11 @@
'has-content':vm.hasContent()}"> 'has-content':vm.hasContent()}">
</markdown-view> </markdown-view>
</excerpt> </excerpt>
<markdown text="vm.state().text" <markdown-editor
text="vm.state().text"
on-edit-text="vm.setText(text)" on-edit-text="vm.setText(text)"
read-only="false"
ng-if="vm.editing()"> ng-if="vm.editing()">
</markdown> </markdown-editor>
</section> </section>
<!-- / Body --> <!-- / Body -->
......
<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>
$toolbar-border: 0.1em solid $grey-3;
.markdown-editor__toolbar {
display: flex;
flex-direction: row;
background-color: white;
border: $toolbar-border;
border-bottom: none;
border-radius: 0.15em 0.15em 0 0;
width: 100%;
margin-bottom: -0.1em;
padding: 5px 5px;
}
.markdown-editor__toolbar-button {
appearance: none;
border: none;
background: none;
color: $grey-5;
font-size: 16px;
&:hover,
&:focus {
color: black;
}
&:disabled {
color: $grey-3;
}
&.is-text {
font-size: 13px;
}
}
.markdown-editor__preview {
border: $toolbar-border;
background-color: $grey-1;
padding: 10px;
}
//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,7 @@ $base-line-height: 20px; ...@@ -32,7 +32,7 @@ $base-line-height: 20px;
@import './components/group-list-item'; @import './components/group-list-item';
@import './components/help-panel'; @import './components/help-panel';
@import './components/logged-out-message'; @import './components/logged-out-message';
@import './components/markdown'; @import './components/markdown-editor';
@import './components/markdown-view'; @import './components/markdown-view';
@import './components/menu'; @import './components/menu';
@import './components/menu-item'; @import './components/menu-item';
......
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