2017-05-08 13:35:42 +03:00
|
|
|
import Component from 'ember-component';
|
|
|
|
import computed from 'ember-computed';
|
|
|
|
import {assign} from 'ember-platform';
|
|
|
|
import {copy} from 'ember-metal/utils';
|
|
|
|
import {isEmpty} from 'ember-utils';
|
|
|
|
import run from 'ember-runloop';
|
2017-05-15 19:51:19 +03:00
|
|
|
import formatMarkdown from 'ghost-admin/utils/format-markdown';
|
2017-05-08 13:35:42 +03:00
|
|
|
|
|
|
|
const MOBILEDOC_VERSION = '0.3.1';
|
|
|
|
|
|
|
|
export const BLANK_DOC = {
|
|
|
|
version: MOBILEDOC_VERSION,
|
|
|
|
markups: [],
|
|
|
|
atoms: [],
|
|
|
|
cards: [
|
|
|
|
['card-markdown', {
|
|
|
|
cardName: 'card-markdown',
|
|
|
|
markdown: ''
|
|
|
|
}]
|
|
|
|
],
|
|
|
|
sections: [[10, 0]]
|
|
|
|
};
|
|
|
|
|
|
|
|
export default Component.extend({
|
|
|
|
|
2017-05-08 21:15:56 +03:00
|
|
|
classNames: ['gh-markdown-editor'],
|
|
|
|
classNameBindings: [
|
|
|
|
'_isFullScreen:gh-markdown-editor-full-screen',
|
|
|
|
'_isSplitScreen:gh-markdown-editor-side-by-side'
|
|
|
|
],
|
|
|
|
|
2017-05-08 13:35:42 +03:00
|
|
|
// Public attributes
|
|
|
|
autofocus: false,
|
2017-05-10 18:16:36 +03:00
|
|
|
imageMimeTypes: null,
|
2017-05-08 21:15:56 +03:00
|
|
|
isFullScreen: false,
|
2017-05-08 13:35:42 +03:00
|
|
|
mobiledoc: null,
|
|
|
|
options: null,
|
|
|
|
placeholder: '',
|
|
|
|
uploadedImageUrls: null,
|
|
|
|
|
|
|
|
// Closure actions
|
|
|
|
onChange() {},
|
2017-05-12 11:06:56 +03:00
|
|
|
onFullScreenToggle() {},
|
2017-05-10 18:16:36 +03:00
|
|
|
onImageFilesSelected() {},
|
2017-05-12 11:06:56 +03:00
|
|
|
onPreviewToggle() {},
|
|
|
|
onSplitScreenToggle() {},
|
2017-05-08 13:35:42 +03:00
|
|
|
showMarkdownHelp() {},
|
|
|
|
|
|
|
|
// Internal attributes
|
|
|
|
markdown: null,
|
|
|
|
|
|
|
|
// Private
|
|
|
|
_editor: null,
|
2017-05-08 21:15:56 +03:00
|
|
|
_isFullScreen: false,
|
|
|
|
_isSplitScreen: false,
|
2017-05-08 13:35:42 +03:00
|
|
|
_isUploading: false,
|
|
|
|
_statusbar: null,
|
|
|
|
_toolbar: null,
|
2017-05-08 21:15:56 +03:00
|
|
|
_uploadedImageUrls: null,
|
2017-05-08 13:35:42 +03:00
|
|
|
|
|
|
|
simpleMDEOptions: computed('options', function () {
|
|
|
|
let options = this.get('options') || {};
|
|
|
|
let defaultOptions = {
|
2017-05-15 19:51:19 +03:00
|
|
|
// use our Showdown config with sanitization for previews
|
|
|
|
previewRender(markdown) {
|
|
|
|
return formatMarkdown(markdown);
|
|
|
|
},
|
|
|
|
|
|
|
|
// Ghost-specific SimpleMDE toolbar config - allows us to create a
|
|
|
|
// bridge between SimpleMDE buttons and Ember actions
|
2017-05-08 13:35:42 +03:00
|
|
|
toolbar: [
|
|
|
|
'bold', 'italic', 'heading', '|',
|
|
|
|
'quote', 'unordered-list', 'ordered-list', '|',
|
2017-05-10 18:16:36 +03:00
|
|
|
'link',
|
|
|
|
{
|
|
|
|
name: 'image',
|
|
|
|
action: () => {
|
|
|
|
this._openImageFileDialog();
|
|
|
|
},
|
|
|
|
className: 'fa fa-picture-o',
|
|
|
|
title: 'Upload Image(s)'
|
|
|
|
},
|
|
|
|
'|',
|
2017-05-12 11:06:56 +03:00
|
|
|
{
|
|
|
|
name: 'preview',
|
|
|
|
action: () => {
|
|
|
|
this._togglePreview();
|
|
|
|
},
|
|
|
|
className: 'fa fa-eye no-disable',
|
|
|
|
title: 'Toggle Preview (Cmd-P)'
|
|
|
|
},
|
2017-05-08 21:15:56 +03:00
|
|
|
{
|
|
|
|
name: 'side-by-side',
|
|
|
|
action: () => {
|
|
|
|
this.send('toggleSplitScreen');
|
|
|
|
},
|
|
|
|
className: 'fa fa-columns no-disable no-mobile',
|
|
|
|
title: 'Toggle Side by Side'
|
|
|
|
},
|
2017-05-08 13:35:42 +03:00
|
|
|
{
|
|
|
|
name: 'fullscreen',
|
|
|
|
action: () => {
|
2017-05-08 21:15:56 +03:00
|
|
|
this.send('toggleFullScreen');
|
2017-05-08 13:35:42 +03:00
|
|
|
},
|
|
|
|
className: 'fa fa-arrows-alt no-disable no-mobile',
|
2017-05-08 21:15:56 +03:00
|
|
|
title: 'Toggle Fullscreen'
|
2017-05-08 13:35:42 +03:00
|
|
|
},
|
|
|
|
'|',
|
2017-05-18 13:08:50 +03:00
|
|
|
{
|
|
|
|
name: 'spellcheck',
|
|
|
|
action: () => {
|
|
|
|
this._toggleSpellcheck();
|
|
|
|
},
|
|
|
|
className: 'fa fa-check',
|
|
|
|
title: 'Toggle Spellcheck'
|
|
|
|
},
|
2017-05-08 13:35:42 +03:00
|
|
|
{
|
|
|
|
name: 'guide',
|
|
|
|
action: () => {
|
|
|
|
this.showMarkdownHelp();
|
|
|
|
},
|
|
|
|
className: 'fa fa-question-circle',
|
|
|
|
title: 'Markdown Guide'
|
|
|
|
}
|
|
|
|
],
|
2017-05-15 19:51:19 +03:00
|
|
|
|
|
|
|
// disable shortcuts for side-by-side and fullscreen because they
|
|
|
|
// trigger interal SimpleMDE methods that will result in broken
|
|
|
|
// layouts
|
2017-05-10 18:16:36 +03:00
|
|
|
shortcuts: {
|
2017-05-12 11:06:56 +03:00
|
|
|
toggleFullScreen: null,
|
|
|
|
togglePreview: null,
|
|
|
|
toggleSideBySide: null
|
2017-05-10 18:16:36 +03:00
|
|
|
},
|
2017-05-15 19:51:19 +03:00
|
|
|
|
|
|
|
// only include the number of words in the status bar
|
2017-05-08 13:35:42 +03:00
|
|
|
status: ['words']
|
|
|
|
};
|
|
|
|
|
|
|
|
return assign(defaultOptions, options);
|
|
|
|
}),
|
|
|
|
|
|
|
|
// extract markdown content from single markdown card
|
|
|
|
didReceiveAttrs() {
|
|
|
|
this._super(...arguments);
|
|
|
|
let mobiledoc = this.get('mobiledoc') || copy(BLANK_DOC, true);
|
|
|
|
|
|
|
|
let uploadedImageUrls = this.get('uploadedImageUrls');
|
|
|
|
if (!isEmpty(uploadedImageUrls) && uploadedImageUrls !== this._uploadedImageUrls) {
|
|
|
|
this._uploadedImageUrls = uploadedImageUrls;
|
|
|
|
|
|
|
|
// must be done afterRender to avoid double modify of mobiledoc in
|
|
|
|
// a single render
|
|
|
|
run.scheduleOnce('afterRender', this, () => {
|
|
|
|
this._insertImages(uploadedImageUrls);
|
2017-05-10 18:16:36 +03:00
|
|
|
// reset the file input so the same file can be selected again
|
|
|
|
this.$('input[type=file]').val('');
|
2017-05-08 13:35:42 +03:00
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// eslint-disable-next-line ember-suave/prefer-destructuring
|
|
|
|
let markdown = mobiledoc.cards[0][1].markdown;
|
|
|
|
this.set('markdown', markdown);
|
2017-05-08 21:15:56 +03:00
|
|
|
|
|
|
|
// use internal values to avoid updating bound values
|
|
|
|
if (!isEmpty(this.get('isFullScreen'))) {
|
|
|
|
this.set('_isFullScreen', this.get('isFullScreen'));
|
|
|
|
}
|
|
|
|
if (!isEmpty(this.get('isSplitScreen'))) {
|
|
|
|
this.set('_isSplitScreen', this.get('isSplitScreen'));
|
|
|
|
}
|
|
|
|
|
|
|
|
this._updateButtonState();
|
2017-05-08 13:35:42 +03:00
|
|
|
},
|
|
|
|
|
|
|
|
_insertImages(urls) {
|
|
|
|
let cm = this._editor.codemirror;
|
|
|
|
|
|
|
|
// loop through urls and generate image markdown
|
|
|
|
let images = urls.map((url) => {
|
2017-05-17 14:27:27 +03:00
|
|
|
let filename = url.split('/').pop();
|
|
|
|
let alt = filename;
|
|
|
|
|
|
|
|
// if we have a normal filename.ext, set alt to filename -ext
|
|
|
|
if (filename.lastIndexOf('.') > 0) {
|
|
|
|
alt = filename.slice(0, filename.lastIndexOf('.'));
|
|
|
|
}
|
|
|
|
|
|
|
|
return `![${alt}](${url})`;
|
2017-05-08 13:35:42 +03:00
|
|
|
});
|
|
|
|
let text = images.join(' ');
|
|
|
|
|
2017-05-10 18:16:36 +03:00
|
|
|
// clicking the image toolbar button will lose the selection so we use
|
|
|
|
// the captured selection to re-select here
|
|
|
|
if (this._imageInsertSelection) {
|
|
|
|
// we want to focus but not re-position
|
|
|
|
this.send('focusEditor', null);
|
|
|
|
|
|
|
|
// re-select and clear the captured selection so drag/drop still
|
|
|
|
// inserts at the correct place
|
|
|
|
cm.setSelection(
|
|
|
|
this._imageInsertSelection.anchor,
|
|
|
|
this._imageInsertSelection.head
|
|
|
|
);
|
|
|
|
this._imageInsertSelection = null;
|
|
|
|
}
|
|
|
|
|
2017-05-08 13:35:42 +03:00
|
|
|
// focus editor and place cursor at end if not already focused
|
|
|
|
if (!cm.hasFocus()) {
|
|
|
|
this.send('focusEditor');
|
|
|
|
}
|
|
|
|
|
|
|
|
// insert at cursor or replace selection then position cursor at end
|
|
|
|
// of inserted text
|
|
|
|
cm.replaceSelection(text, 'end');
|
|
|
|
},
|
|
|
|
|
2017-05-18 13:08:50 +03:00
|
|
|
// mark the split-pane/full-screen/spellcheck buttons active when they're active
|
2017-05-08 21:15:56 +03:00
|
|
|
_updateButtonState() {
|
|
|
|
if (this._editor) {
|
|
|
|
let fullScreenButton = this._editor.toolbarElements.fullscreen;
|
|
|
|
let sideBySideButton = this._editor.toolbarElements['side-by-side'];
|
2017-05-18 13:08:50 +03:00
|
|
|
let spellcheckButton = this._editor.toolbarElements.spellcheck;
|
2017-05-08 21:15:56 +03:00
|
|
|
|
|
|
|
if (this.get('_isFullScreen')) {
|
|
|
|
fullScreenButton.classList.add('active');
|
|
|
|
} else {
|
|
|
|
fullScreenButton.classList.remove('active');
|
|
|
|
}
|
|
|
|
|
|
|
|
if (this.get('_isSplitScreen')) {
|
|
|
|
sideBySideButton.classList.add('active');
|
|
|
|
} else {
|
|
|
|
sideBySideButton.classList.remove('active');
|
|
|
|
}
|
2017-05-18 13:08:50 +03:00
|
|
|
|
|
|
|
if (this._editor.codemirror.getOption('mode') === 'spell-checker') {
|
|
|
|
spellcheckButton.classList.add('active');
|
|
|
|
} else {
|
|
|
|
spellcheckButton.classList.remove('active');
|
|
|
|
}
|
2017-05-08 21:15:56 +03:00
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
// set up the preview auto-update and scroll sync
|
|
|
|
_connectSplitPreview() {
|
|
|
|
let cm = this._editor.codemirror;
|
|
|
|
let editor = this._editor;
|
|
|
|
/* eslint-disable ember-suave/prefer-destructuring */
|
|
|
|
let editorPane = this.$('.gh-markdown-editor-pane')[0];
|
|
|
|
let previewPane = this.$('.gh-markdown-editor-preview')[0];
|
|
|
|
let previewContent = this.$('.gh-markdown-editor-preview-content')[0];
|
|
|
|
/* eslint-enable ember-suave/prefer-destructuring */
|
|
|
|
|
|
|
|
this._editorPane = editorPane;
|
|
|
|
this._previewPane = previewPane;
|
|
|
|
this._previewContent = previewContent;
|
|
|
|
|
|
|
|
// from SimpleMDE -------
|
|
|
|
let sideBySideRenderingFunction = function() {
|
|
|
|
previewContent.innerHTML = editor.options.previewRender(
|
|
|
|
editor.value(),
|
|
|
|
previewContent
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
cm.sideBySideRenderingFunction = sideBySideRenderingFunction;
|
|
|
|
|
|
|
|
sideBySideRenderingFunction();
|
|
|
|
cm.on('update', cm.sideBySideRenderingFunction);
|
|
|
|
|
|
|
|
// Refresh to fix selection being off (#309)
|
|
|
|
cm.refresh();
|
|
|
|
// ----------------------
|
|
|
|
|
|
|
|
this._onEditorPaneScroll = this._scrollHandler.bind(this);
|
|
|
|
editorPane.addEventListener('scroll', this._onEditorPaneScroll, false);
|
|
|
|
this._scrollSync();
|
|
|
|
},
|
|
|
|
|
|
|
|
_scrollHandler() {
|
|
|
|
if (!this._scrollSyncTicking) {
|
|
|
|
requestAnimationFrame(this._scrollSync.bind(this));
|
|
|
|
}
|
|
|
|
this._scrollSyncTicking = true;
|
|
|
|
},
|
|
|
|
|
|
|
|
_scrollSync() {
|
|
|
|
let editorPane = this._editorPane;
|
|
|
|
let previewPane = this._previewPane;
|
|
|
|
let height = editorPane.scrollHeight - editorPane.clientHeight;
|
|
|
|
let ratio = parseFloat(editorPane.scrollTop) / height;
|
|
|
|
let move = (previewPane.scrollHeight - previewPane.clientHeight) * ratio;
|
|
|
|
|
|
|
|
previewPane.scrollTop = move;
|
|
|
|
this._scrollSyncTicking = false;
|
|
|
|
},
|
|
|
|
|
|
|
|
_disconnectSplitPreview() {
|
|
|
|
let cm = this._editor.codemirror;
|
|
|
|
|
|
|
|
cm.off('update', cm.sideBySideRenderingFunction);
|
|
|
|
cm.refresh();
|
|
|
|
|
|
|
|
this._editorPane.removeEventListener('scroll', this._onEditorPaneScroll, false);
|
|
|
|
delete this._previewPane;
|
|
|
|
delete this._previewPaneContent;
|
|
|
|
delete this._onEditorPaneScroll;
|
|
|
|
},
|
|
|
|
|
2017-05-10 18:16:36 +03:00
|
|
|
_openImageFileDialog() {
|
|
|
|
// capture the current selection before it's lost by clicking the
|
|
|
|
// file input button
|
|
|
|
this._imageInsertSelection = {
|
|
|
|
anchor: this._editor.codemirror.getCursor('anchor'),
|
|
|
|
head: this._editor.codemirror.getCursor('head')
|
|
|
|
};
|
|
|
|
|
|
|
|
// trigger the dialog via gh-file-input, when a file is selected it will
|
|
|
|
// trigger the onImageFilesSelected closure action
|
|
|
|
this.$('input[type="file"]').click();
|
|
|
|
},
|
|
|
|
|
2017-05-12 11:06:56 +03:00
|
|
|
// wrap SimpleMDE's built-in preview toggle so that we can trigger a closure
|
|
|
|
// action that can apply our own classes higher up in the DOM
|
|
|
|
_togglePreview() {
|
|
|
|
this.onPreviewToggle(!this._editor.isPreviewActive());
|
|
|
|
this._editor.togglePreview();
|
|
|
|
},
|
|
|
|
|
2017-05-18 13:08:50 +03:00
|
|
|
_toggleSpellcheck() {
|
|
|
|
let cm = this._editor.codemirror;
|
|
|
|
|
|
|
|
if (cm.getOption('mode') === 'spell-checker') {
|
|
|
|
cm.setOption('mode', 'markdown');
|
|
|
|
} else {
|
|
|
|
cm.setOption('mode', 'spell-checker');
|
|
|
|
}
|
|
|
|
|
|
|
|
this._updateButtonState();
|
|
|
|
},
|
|
|
|
|
2017-05-08 21:15:56 +03:00
|
|
|
willDestroyElement() {
|
|
|
|
if (this.get('_isSplitScreen')) {
|
|
|
|
this._disconnectSplitPreview();
|
|
|
|
}
|
|
|
|
|
|
|
|
this._super(...arguments);
|
|
|
|
},
|
|
|
|
|
2017-05-08 13:35:42 +03:00
|
|
|
actions: {
|
|
|
|
// put the markdown into a new mobiledoc card, trigger external update
|
|
|
|
updateMarkdown(markdown) {
|
|
|
|
let mobiledoc = copy(BLANK_DOC, true);
|
|
|
|
mobiledoc.cards[0][1].markdown = markdown;
|
|
|
|
this.onChange(mobiledoc);
|
|
|
|
},
|
|
|
|
|
|
|
|
// store a reference to the simplemde editor so that we can handle
|
|
|
|
// focusing and image uploads
|
|
|
|
setEditor(editor) {
|
|
|
|
this._editor = editor;
|
|
|
|
|
|
|
|
// disable CodeMirror's drag/drop handling as we want to handle that
|
|
|
|
// in the parent gh-editor component
|
|
|
|
this._editor.codemirror.setOption('dragDrop', false);
|
|
|
|
|
|
|
|
// HACK: move the toolbar & status bar elements outside of the
|
|
|
|
// editor container so that they can be aligned in fixed positions
|
|
|
|
let container = this.$().closest('.gh-editor').find('.gh-editor-footer');
|
|
|
|
this._toolbar = this.$('.editor-toolbar');
|
|
|
|
this._statusbar = this.$('.editor-statusbar');
|
|
|
|
this._toolbar.appendTo(container);
|
|
|
|
this._statusbar.appendTo(container);
|
|
|
|
|
2017-05-08 21:15:56 +03:00
|
|
|
this._updateButtonState();
|
2017-05-08 13:35:42 +03:00
|
|
|
},
|
|
|
|
|
|
|
|
// used by the title input when the TAB or ENTER keys are pressed
|
|
|
|
focusEditor(position = 'bottom') {
|
|
|
|
this._editor.codemirror.focus();
|
|
|
|
|
|
|
|
if (position === 'bottom') {
|
|
|
|
this._editor.codemirror.execCommand('goDocEnd');
|
|
|
|
} else if (position === 'top') {
|
|
|
|
this._editor.codemirror.execCommand('goDocStart');
|
|
|
|
}
|
|
|
|
|
|
|
|
return false;
|
2017-05-08 21:15:56 +03:00
|
|
|
},
|
|
|
|
|
|
|
|
toggleFullScreen() {
|
|
|
|
let isFullScreen = !this.get('_isFullScreen');
|
|
|
|
|
|
|
|
this.set('_isFullScreen', isFullScreen);
|
|
|
|
this._updateButtonState();
|
2017-05-12 11:06:56 +03:00
|
|
|
this.onFullScreenToggle(isFullScreen);
|
2017-05-08 21:15:56 +03:00
|
|
|
|
|
|
|
// leave split screen when exiting full screen mode
|
|
|
|
if (!isFullScreen && this.get('_isSplitScreen')) {
|
|
|
|
this.send('toggleSplitScreen');
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
toggleSplitScreen() {
|
|
|
|
let isSplitScreen = !this.get('_isSplitScreen');
|
2017-05-10 15:36:07 +03:00
|
|
|
let previewButton = this._editor.toolbarElements.preview;
|
2017-05-08 21:15:56 +03:00
|
|
|
|
|
|
|
this.set('_isSplitScreen', isSplitScreen);
|
|
|
|
this._updateButtonState();
|
|
|
|
|
|
|
|
// set up the preview rendering and scroll sync
|
|
|
|
// afterRender is needed so that necessary components have been
|
|
|
|
// added/removed and editor pane length has settled
|
|
|
|
if (isSplitScreen) {
|
2017-05-10 15:36:07 +03:00
|
|
|
// disable the normal SimpleMDE preview if it's active
|
|
|
|
if (this._editor.isPreviewActive()) {
|
|
|
|
let preview = this._editor.toolbar.find((button) => {
|
|
|
|
return button.name === 'preview';
|
|
|
|
});
|
|
|
|
|
|
|
|
preview.action(this._editor);
|
|
|
|
}
|
|
|
|
|
|
|
|
previewButton.classList.add('disabled');
|
2017-05-08 21:15:56 +03:00
|
|
|
run.scheduleOnce('afterRender', this, this._connectSplitPreview);
|
|
|
|
} else {
|
2017-05-10 15:36:07 +03:00
|
|
|
previewButton.classList.remove('disabled');
|
2017-05-08 21:15:56 +03:00
|
|
|
run.scheduleOnce('afterRender', this, this._disconnectSplitPreview);
|
|
|
|
}
|
|
|
|
|
2017-05-12 11:06:56 +03:00
|
|
|
this.onSplitScreenToggle(isSplitScreen);
|
2017-05-08 21:15:56 +03:00
|
|
|
|
|
|
|
// go fullscreen when entering split screen mode
|
|
|
|
if (isSplitScreen && !this.get('_isFullScreen')) {
|
|
|
|
this.send('toggleFullScreen');
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
// put the toolbar/statusbar elements back so that SimpleMDE doesn't throw
|
|
|
|
// errors when it tries to remove them
|
|
|
|
destroyEditor() {
|
|
|
|
let container = this.$('.gh-markdown-editor-pane');
|
|
|
|
this._toolbar.appendTo(container);
|
|
|
|
this._statusbar.appendTo(container);
|
|
|
|
this._editor = null;
|
2017-05-08 13:35:42 +03:00
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|