SimpleMDE editor (#682)

no issue

* move "save on first change" behaviour into editor controller
* allow TAB events to be specified in keyEvents hash of gh-input
* replace mobiledoc-kit/gh-koenig with a SimpleMDE based editor
    - remove `gh-koenig` in-repo-addon from `package.json` so that test files etc aren't loaded
    - remove `mobiledoc-kit` dependencies
    - extends `gh-editor` to handle file drag/drop
    - adds `gh-uploader` and `gh-progress-bar` components to handle file uploads in a more composable manner
    - adds `gh-simplemde` component that wraps SimpleMDE
This commit is contained in:
Kevin Ansfield 2017-05-08 11:35:42 +01:00 committed by Hannah Wolfe
parent 756b6627a9
commit 762c3c4df0
28 changed files with 1134 additions and 153 deletions

View File

@ -1,13 +1,35 @@
import Component from 'ember-component'; import Component from 'ember-component';
import run from 'ember-runloop'; import run from 'ember-runloop';
import {
IMAGE_MIME_TYPES,
IMAGE_EXTENSIONS
} from 'ghost-admin/components/gh-image-uploader';
const {debounce} = run; const {debounce} = run;
export default Component.extend({ export default Component.extend({
headerClass: '', classNameBindings: ['isDraggedOver:-drag-over'],
// Public attributes
navIsClosed: false,
// Internal attributes
droppedFiles: null,
headerClass: '',
imageExtensions: IMAGE_EXTENSIONS,
imageMimeTypes: IMAGE_MIME_TYPES,
isDraggedOver: false,
uploadedImageUrls: null,
// Closure actions
toggleAutoNav() {},
// Private
_dragCounter: 0,
_fullScreenEnabled: false,
_navIsClosed: false, _navIsClosed: false,
_onResizeHandler: null,
_viewActionsWidth: 190, _viewActionsWidth: 190,
init() { init() {
@ -27,22 +49,18 @@ export default Component.extend({
let navIsClosed = this.get('navIsClosed'); let navIsClosed = this.get('navIsClosed');
if (navIsClosed !== this._navIsClosed) { if (navIsClosed !== this._navIsClosed) {
this._fullScreenEnabled = navIsClosed;
run.scheduleOnce('afterRender', this, this._setHeaderClass); run.scheduleOnce('afterRender', this, this._setHeaderClass);
} }
this._navIsClosed = navIsClosed; this._navIsClosed = navIsClosed;
}, },
willDestroyElement() {
this._super(...arguments);
window.removeEventListener('resize', this._onResizeHandler);
},
_setHeaderClass() { _setHeaderClass() {
let $editorInner = this.$('.gh-editor-inner'); let $editorTitle = this.$('.gh-editor-title');
if ($editorInner.length > 0) { if ($editorTitle.length > 0) {
let boundingRect = $editorInner[0].getBoundingClientRect(); let boundingRect = $editorTitle[0].getBoundingClientRect();
let maxRight = window.innerWidth - this._viewActionsWidth; let maxRight = window.innerWidth - this._viewActionsWidth;
if (boundingRect.right >= maxRight) { if (boundingRect.right >= maxRight) {
@ -52,5 +70,89 @@ export default Component.extend({
} }
this.set('headerClass', ''); this.set('headerClass', '');
},
// dragOver is needed so that drop works
dragOver(event) {
if (!event.dataTransfer) {
return;
}
// this is needed to work around inconsistencies with dropping files
// from Chrome's downloads bar
let eA = event.dataTransfer.effectAllowed;
event.dataTransfer.dropEffect = (eA === 'move' || eA === 'linkMove') ? 'move' : 'copy';
event.preventDefault();
event.stopPropagation();
},
// dragEnter is needed so that the drag class is correctly removed
dragEnter(event) {
if (!event.dataTransfer) {
return;
}
event.preventDefault();
event.stopPropagation();
// the counter technique prevents flickering of the drag class when
// dragging across child elements
this._dragCounter++;
this.set('isDraggedOver', true);
},
dragLeave(event) {
event.preventDefault();
event.stopPropagation();
this._dragCounter--;
if (this._dragCounter === 0) {
this.set('isDraggedOver', false);
}
},
drop(event) {
event.preventDefault();
event.stopPropagation();
this._dragCounter = 0;
this.set('isDraggedOver', false);
if (event.dataTransfer.files) {
this.set('droppedFiles', event.dataTransfer.files);
}
},
willDestroyElement() {
this._super(...arguments);
window.removeEventListener('resize', this._onResizeHandler);
// reset fullscreen mode if it was turned on
if (this._fullScreenEnabled) {
this.toggleAutoNav();
}
},
actions: {
toggleFullScreen() {
if (!this._fullScreenWasToggled) {
this._fullScreenEnabled = !this.get('isNavOpen');
this._fullScreenWasToggled = true;
} else {
this._fullScreenEnabled = !this._fullScreenEnabled;
}
this.toggleAutoNav();
},
uploadComplete(uploads) {
this.set('uploadedImageUrls', uploads.mapBy('url'));
this.set('droppedFiles', null);
},
uploadCancelled() {
this.set('droppedFiles', null);
}
} }
}); });

View File

@ -14,6 +14,9 @@ import {
UnsupportedMediaTypeError UnsupportedMediaTypeError
} from 'ghost-admin/services/ajax'; } from 'ghost-admin/services/ajax';
export const IMAGE_MIME_TYPES = 'image/gif,image/jpg,image/jpeg,image/png,image/svg+xml';
export const IMAGE_EXTENSIONS = ['gif', 'jpg', 'jpeg', 'png', 'svg'];
export default Component.extend({ export default Component.extend({
tagName: 'section', tagName: 'section',
classNames: ['gh-image-uploader'], classNames: ['gh-image-uploader'],
@ -37,8 +40,8 @@ export default Component.extend({
ajax: injectService(), ajax: injectService(),
notifications: injectService(), notifications: injectService(),
_defaultAccept: 'image/gif,image/jpg,image/jpeg,image/png,image/svg+xml', _defaultAccept: IMAGE_MIME_TYPES,
_defaultExtensions: ['gif', 'jpg', 'jpeg', 'png', 'svg'], _defaultExtensions: IMAGE_EXTENSIONS,
_defaultUploadUrl: '/uploads/', _defaultUploadUrl: '/uploads/',
// TODO: this wouldn't be necessary if the server could accept direct // TODO: this wouldn't be necessary if the server could accept direct

View File

@ -2,5 +2,14 @@ import OneWayInput from 'ember-one-way-controls/components/one-way-input';
import TextInputMixin from 'ghost-admin/mixins/text-input'; import TextInputMixin from 'ghost-admin/mixins/text-input';
export default OneWayInput.extend(TextInputMixin, { export default OneWayInput.extend(TextInputMixin, {
classNames: 'gh-input' classNames: 'gh-input',
// prevent default TAB behaviour if we have a keyEvent for it
keyDown(event) {
if (event.keyCode === 9 && this.get('keyEvents.9')) {
event.preventDefault();
}
this._super(...arguments);
}
}); });

View File

@ -0,0 +1,169 @@
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';
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({
// Public attributes
autofocus: false,
mobiledoc: null,
options: null,
placeholder: '',
uploadedImageUrls: null,
// Closure actions
onChange() {},
onFullScreen() {},
showMarkdownHelp() {},
// Internal attributes
markdown: null,
// Private
_editor: null,
_isUploading: false,
_uploadedImageUrls: null,
_statusbar: null,
_toolbar: null,
// Ghost-Specific SimpleMDE toolbar config - allows us to create a bridge
// between SimpleMDE buttons and Ember actions
simpleMDEOptions: computed('options', function () {
let options = this.get('options') || {};
let defaultOptions = {
toolbar: [
'bold', 'italic', 'heading', '|',
'quote', 'unordered-list', 'ordered-list', '|',
'link', 'image', '|',
'preview', 'side-by-side',
{
name: 'fullscreen',
action: () => {
this.onFullScreen();
},
className: 'fa fa-arrows-alt no-disable no-mobile',
title: 'Toggle Fullscreen (F11)'
},
'|',
{
name: 'guide',
action: () => {
this.showMarkdownHelp();
},
className: 'fa fa-question-circle',
title: 'Markdown Guide'
}
],
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);
});
}
// eslint-disable-next-line ember-suave/prefer-destructuring
let markdown = mobiledoc.cards[0][1].markdown;
this.set('markdown', markdown);
},
_insertImages(urls) {
let cm = this._editor.codemirror;
// loop through urls and generate image markdown
let images = urls.map((url) => {
return `![](${url})`;
});
let text = images.join(' ');
// 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');
},
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);
},
// put the toolbar/statusbar elements back so that SimpleMDE doesn't throw
// errors when it tries to remove them
destroyEditor() {
let container = this.$();
this._toolbar.appendTo(container);
this._statusbar.appendTo(container);
this._editor = null;
},
// 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;
}
}
});

View File

@ -0,0 +1,23 @@
import Component from 'ember-component';
import {htmlSafe} from 'ember-string';
export default Component.extend({
tagName: '',
// Public attributes
percentage: 0,
isError: false,
// Internal attributes
progressStyle: '',
didReceiveAttrs() {
this._super(...arguments);
let percentage = this.get('percentage');
let width = (percentage > 0) ? `${percentage}%` : '0';
this.set('progressStyle', htmlSafe(`width: ${width}`));
}
});

View File

@ -0,0 +1,103 @@
/* global SimpleMDE */
import Ember from 'ember';
import TextArea from 'ember-components/text-area';
import computed from 'ember-computed';
import {assign} from 'ember-platform';
import {isEmpty} from 'ember-utils';
// ember-cli-shims doesn't export Ember.testing
const {testing} = Ember;
export default TextArea.extend({
// Public attributes
autofocus: false,
options: null,
value: null,
placeholder: '',
// Closure actions
onChange() {},
onEditorInit() {},
onEditorDestroy() {},
// Private
_editor: null,
// default SimpleMDE options, see docs for available config:
// https://github.com/NextStepWebs/simplemde-markdown-editor#configuration
defaultOptions: computed(function () {
return {
autofocus: this.get('autofocus'),
indentWithTabs: false,
placeholder: this.get('placeholder'),
shortcuts: {
toggleSideBySide: null,
toggleFullScreen: null
},
tabSize: 4
};
}),
init() {
this._super(...arguments);
if (isEmpty(this.get('options'))) {
this.set('options', {});
}
},
// instantiate the editor with the contents of value
didInsertElement() {
this._super(...arguments);
let editorOptions = assign(
{element: document.getElementById(this.elementId)},
this.get('defaultOptions'),
this.get('options')
);
// disable spellchecker when testing so that the exterally loaded plugin
// doesn't fail
if (testing) {
editorOptions.spellChecker = false;
}
this._editor = new SimpleMDE(editorOptions);
this._editor.value(this.get('value') || '');
this._editor.codemirror.on('change', () => {
this.onChange(this._editor.value());
});
if (this.get('autofocus')) {
this._editor.codemirror.execCommand('goDocEnd');
}
this.onEditorInit(this._editor);
},
// update the editor when the value property changes from the outside
didReceiveAttrs() {
this._super(...arguments);
if (isEmpty(this._editor)) {
return;
}
// compare values before forcing a content reset to avoid clobbering
// the undo behaviour
if (this.get('value') !== this._editor.value()) {
let cursor = this._editor.codemirror.getDoc().getCursor();
this._editor.value(this.get('value'));
this._editor.codemirror.getDoc().setCursor(cursor);
}
},
willDestroyElement() {
this.onEditorDestroy();
this._editor.toTextArea();
delete this._editor;
this._super(...arguments);
}
});

View File

@ -0,0 +1,254 @@
import Component from 'ember-component';
import {isEmberArray} from 'ember-array/utils';
import injectService from 'ember-service/inject';
import {isEmpty} from 'ember-utils';
import {task, all} from 'ember-concurrency';
import ghostPaths from 'ghost-admin/utils/ghost-paths';
import EmberObject from 'ember-object';
// TODO: this is designed to be a more re-usable/composable upload component, it
// should be able to replace the duplicated upload logic in:
// - gh-image-uploader
// - gh-file-uploader
// - gh-koenig/cards/card-image
// - gh-koenig/cards/card-markdown
//
// In order to support the above components we'll need to introduce an
// "allowMultiple" attribute so that single-image uploads don't allow multiple
// simultaneous uploads
const UploadTracker = EmberObject.extend({
file: null,
total: 0,
loaded: 0,
init() {
this.total = this.file && this.file.size || 0;
},
update({loaded, total}) {
this.total = total;
this.loaded = loaded;
}
});
export default Component.extend({
tagName: '',
ajax: injectService(),
notifications: injectService(),
// Public attributes
accept: '',
extensions: null,
files: null,
paramName: 'uploadimage', // TODO: is this the best default?
uploadUrl: null,
// Interal attributes
errors: null, // [{fileName: 'x', message: 'y'}, ...]
totalSize: 0,
uploadedSize: 0,
uploadPercentage: 0,
uploadUrls: null, // [{filename: 'x', url: 'y'}],
// Private
_defaultUploadUrl: '/uploads/',
_files: null,
_isUploading: false,
_uploadTrackers: null,
// Closure actions
onCancel() {},
onComplete() {},
onFailed() {},
onStart() {},
onUploadFail() {},
onUploadSuccess() {},
// Optional closure actions
// validate(file) {}
init() {
this._super(...arguments);
this.set('errors', []);
this.set('uploadUrls', []);
this._uploadTrackers = [];
},
didReceiveAttrs() {
this._super(...arguments);
// set up any defaults
if (!this.get('uploadUrl')) {
this.set('uploadUrl', this._defaultUploadUrl);
}
// if we have new files, validate and start an upload
let files = this.get('files');
if (files && files !== this._files) {
if (this._isUploading) {
throw new Error('Adding new files whilst an upload is in progress is not supported.');
}
this._files = files;
// we cancel early if any file fails client-side validation
if (this._validate()) {
this.get('_uploadFiles').perform(files);
}
}
},
_validate() {
let files = this.get('files');
let validate = this.get('validate') || this._defaultValidator.bind(this);
let ok = [];
let errors = [];
for (let file of files) {
let result = validate(file);
if (result === true) {
ok.push(file);
} else {
errors.push({fileName: file.name, message: result});
}
}
if (isEmpty(errors)) {
return true;
}
this.set('errors', errors);
this.onFailed(errors);
return false;
},
// we only check the file extension by default because IE doesn't always
// expose the mime-type, we'll rely on the API for final validation
_defaultValidator(file) {
let extensions = this.get('extensions');
let [, extension] = (/(?:\.([^.]+))?$/).exec(file.name);
if (!isEmberArray(extensions)) {
extensions = extensions.split(',');
}
if (!extension || extensions.indexOf(extension.toLowerCase()) === -1) {
let validExtensions = `.${extensions.join(', .').toUpperCase()}`;
return `The image type you uploaded is not supported. Please use ${validExtensions}`;
}
return true;
},
_uploadFiles: task(function* (files) {
let uploads = [];
this.onStart();
for (let file of files) {
uploads.push(this.get('_uploadFile').perform(file));
}
// populates this.errors and this.uploadUrls
yield all(uploads);
this.onComplete(this.get('uploadUrls'));
}).drop(),
_uploadFile: task(function* (file) {
let ajax = this.get('ajax');
let formData = this._getFormData(file);
let url = `${ghostPaths().apiRoot}${this.get('uploadUrl')}`;
let tracker = new UploadTracker({file});
this.get('_uploadTrackers').pushObject(tracker);
try {
let response = yield ajax.post(url, {
data: formData,
processData: false,
contentType: false,
dataType: 'text',
xhr: () => {
let xhr = new window.XMLHttpRequest();
xhr.upload.addEventListener('progress', (event) => {
tracker.update(event);
this._updateProgress();
}, false);
return xhr;
}
});
// TODO: is it safe to assume we'll only get a url back?
let uploadUrl = JSON.parse(response);
this.get('uploadUrls').push({
fileName: file.name,
url: uploadUrl
});
return true;
} catch (error) {
console.log('error', error); // eslint-disable-line
// TODO: check for or expose known error types?
this.get('errors').push({
fileName: file.name,
message: error.errors[0].message
});
}
}),
// NOTE: this is necessary because the API doesn't accept direct file uploads
_getFormData(file) {
let formData = new FormData();
formData.append(this.get('paramName'), file);
return formData;
},
// TODO: this was needed because using CPs directly resulted in infrequent updates
// - I think this was because updates were being wrapped up to save
// computation but that hypothesis needs testing
_updateProgress() {
let trackers = this._uploadTrackers;
let totalSize = trackers.reduce((total, tracker) => {
return total + tracker.get('total');
}, 0);
let uploadedSize = trackers.reduce((total, tracker) => {
return total + tracker.get('loaded');
}, 0);
this.set('totalSize', totalSize);
this.set('uploadedSize', uploadedSize);
if (totalSize === 0 || uploadedSize === 0) {
return;
}
let uploadPercentage = Math.round((uploadedSize / totalSize) * 100);
this.set('uploadPercentage', uploadPercentage);
},
_reset() {
this.set('errors', null);
this.set('totalSize', 0);
this.set('uploadedSize', 0);
this.set('uploadPercentage', 0);
this._uploadTrackers = [];
this._isUploading = false;
},
actions: {
cancel() {
this._reset();
this.onCancel();
}
}
});

View File

@ -56,6 +56,8 @@ export default Mixin.create({
navIsClosed: reads('application.autoNav'), navIsClosed: reads('application.autoNav'),
_hasChanged: false,
init() { init() {
this._super(...arguments); this._super(...arguments);
window.onbeforeunload = () => { window.onbeforeunload = () => {
@ -466,6 +468,16 @@ export default Mixin.create({
actions: { actions: {
updateScratch(value) { updateScratch(value) {
this.set('model.scratch', value); this.set('model.scratch', value);
// save on first change to trigger the new->edit transition
if (!this._hasChanged && this.get('model.isNew')) {
this._hasChanged = true;
this.send('save', {silent: true, backgroundSave: true});
return;
}
this._hasChanged = true;
// save 3 seconds after last edit // save 3 seconds after last edit
this.get('_autosave').perform(); this.get('_autosave').perform();
// force save at 60 seconds // force save at 60 seconds
@ -494,12 +506,6 @@ export default Mixin.create({
} }
}, },
autoSaveNew() {
if (this.get('model.isNew')) {
this.send('save', {silent: true, backgroundSave: true});
}
},
closeNavMenu() { closeNavMenu() {
this.get('application').send('closeAutoNav'); this.get('application').send('closeAutoNav');
}, },
@ -573,6 +579,10 @@ export default Mixin.create({
setWordcount(wordcount) { setWordcount(wordcount) {
this.set('wordcount', wordcount); this.set('wordcount', wordcount);
},
toggleAutoNav() {
this.get('application').send('toggleAutoNav');
} }
} }
}); });

View File

@ -132,6 +132,9 @@ export default Mixin.create(styleBody, ShortcutsRoute, {
controller.set('previousTagNames', []); controller.set('previousTagNames', []);
} }
// reset save-on-first-change
controller._hasChanged = false;
// attach model-related listeners created in editor-base-route // attach model-related listeners created in editor-base-route
this.attachModelHooks(controller, model); this.attachModelHooks(controller, model);
} }

View File

@ -10,7 +10,8 @@ import ValidationEngine from 'ghost-admin/mixins/validation-engine';
import boundOneWay from 'ghost-admin/utils/bound-one-way'; import boundOneWay from 'ghost-admin/utils/bound-one-way';
import {isBlank} from 'ember-utils'; import {isBlank} from 'ember-utils';
import {BLANK_DOC} from 'ghost-admin/components/gh-koenig'; // a blank mobile doc // a blank mobile doc containing a single markdown card
import {BLANK_DOC} from 'ghost-admin/components/gh-markdown-editor';
// ember-cli-shims doesn't export these so we must get them manually // ember-cli-shims doesn't export these so we must get them manually
const {Comparable, compare} = Ember; const {Comparable, compare} = Ember;

View File

@ -53,7 +53,7 @@
/* Addons: gh-koenig /* Addons: gh-koenig
/* ---------------------------------------------------------- */ /* ---------------------------------------------------------- */
@import "addons/gh-koenig/gh-koenig.css"; /*@import "addons/gh-koenig/gh-koenig.css";*/
:root { :root {

View File

@ -53,7 +53,7 @@
/* Addons: gh-koenig /* Addons: gh-koenig
/* ---------------------------------------------------------- */ /* ---------------------------------------------------------- */
@import "addons/gh-koenig/gh-koenig.css"; /*@import "addons/gh-koenig/gh-koenig.css";*/
/* ---------------------------✈️----------------------------- */ /* ---------------------------✈️----------------------------- */

View File

@ -89,7 +89,14 @@
cursor: pointer; cursor: pointer;
} }
.gh-image-uploader .progress-container { .gh-image-uploader .failed {
margin: 1em 2em;
font-size: 16px;
}
/* TODO: remove the gh-image-uploader classes once it's using gh-progrss-bar */
.gh-image-uploader .progress-container,
.gh-progress-container {
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -97,7 +104,8 @@
width: 100%; width: 100%;
} }
.gh-image-uploader .progress { .gh-image-uploader .progress,
.gh-progress-container-progress {
overflow: hidden; overflow: hidden;
margin: 0 auto; margin: 0 auto;
width: 60%; width: 60%;
@ -106,17 +114,14 @@
box-shadow: rgba(0, 0, 0, 0.1) 0 1px 2px inset; box-shadow: rgba(0, 0, 0, 0.1) 0 1px 2px inset;
} }
.gh-image-uploader .failed { .gh-image-uploader .bar,
margin: 1em 2em; .gh-progress-bar {
font-size: 16px;
}
.gh-image-uploader .bar {
height: 12px; height: 12px;
background: var(--blue); background: var(--blue);
} }
.gh-image-uploader .bar.fail { .gh-image-uploader .bar.fail,
.gh-progress-bar.-error {
width: 100% !important; width: 100% !important;
background: var(--red); background: var(--red);
} }

View File

@ -156,25 +156,6 @@
.gh-editor-header { padding: 0 4vw; } .gh-editor-header { padding: 0 4vw; }
} }
.gh-editor-status {
color: var(--midgrey);
font-weight: 300;
}
.gh-editor-container {
position: absolute;
overflow-y: auto;
padding: 10vw 4vw;
width: 100%;
height: 100%;
}
.gh-editor-inner {
margin: 0 auto;
max-width: 740px;
height: 100%;
}
.gh-editor-header-small { .gh-editor-header-small {
height: 43px; height: 43px;
padding: 0; padding: 0;
@ -192,10 +173,42 @@
padding: 13px 15px; padding: 13px 15px;
} }
.gh-editor-status {
color: var(--midgrey);
font-weight: 300;
}
.gh-editor-container {
position: absolute;
overflow-y: auto;
padding: 10vw 4vw;
width: 100%;
height: 100%;
z-index: 0;
}
.gh-editor-inner {
margin: 0 auto;
max-width: 760px;
height: 100%;
}
.gh-editor-title {
width: 100%;
min-height: 1.3em;
margin-bottom: 2vw;
border: none;
letter-spacing: 0.8px;
font-weight: bold;
font-size: 3.2rem;
line-height: 1.3em;
}
.gh-editor-wordcount { .gh-editor-wordcount {
position: fixed; position: fixed;
bottom: 0px; bottom: 0px;
padding:10px; padding:10px;
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
@ -203,3 +216,104 @@
display: none; display: none;
} }
} }
/* SimpleMDE editor
/* ---------------------------------------------------------- */
.gh-editor-container {
position: relative;
}
.gh-editor-footer {
height: 48px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
border-top: 1px solid var(--lightgrey);
}
.gh-editor-footer .editor-toolbar {
border: none;
border-radius: 0;
background-color: #fff;
opacity: 1;
}
.gh-editor-footer .editor-toolbar:before,
.gh-editor-footer .editor-toolbar:after {
content: none;
}
.gh-editor-footer .editor-toolbar a:focus {
outline: none;
}
.gh-editor .CodeMirror {
padding: 0;
overflow: visible;
}
.gh-editor .gh-editor-title,
.gh-editor .CodeMirror-wrap {
max-width: 760px;
margin: 0 auto;
border: none;
}
.gh-editor .CodeMirror pre {
padding: 0;
}
.gh-editor .editor-preview {
height: auto;
margin-top: 4px;
padding: 0;
background-color: #fff;
}
.gh-editor-drop-target,
.gh-editor-image-upload {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid blue;
content: '';
z-index: 9999;
background-color: rgba(255,255,255,0.6);
}
.gh-editor-drop-target .drop-target-message {
padding: 1em;
background-color: #fff;
border-radius: 1em;
}
.gh-editor-image-upload.-error {
border: 2px solid red;
}
.gh-editor-image-upload-content {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
padding: 1em;
background-color: #fff;
border-radius: 1em;
max-width: 80%;
}
.gh-editor-image-upload .gh-progress-container-progress {
width: 100%;
}
/* TODO: need a way to make stroke + fill global without causing issues with certain icons */
.gh-editor-image-upload .gh-btn-grey svg path {
stroke: color(var(--darkgrey) l(+15%));
}

View File

@ -1 +1,11 @@
{{yield headerClass}} {{yield (hash
headerClass=headerClass
isDraggedOver=isDraggedOver
droppedFiles=droppedFiles
uploadedImageUrls=uploadedImageUrls
imageMimeTypes=imageMimeTypes
imageExtensions=imageExtensions
toggleFullScreen=(action "toggleFullScreen")
uploadComplete=(action "uploadComplete")
uploadCancelled=(action "uploadCancelled")
)}}

View File

@ -0,0 +1,11 @@
{{yield (hash
pane=(component "gh-simplemde"
value=markdown
placeholder=placeholder
autofocus=autofocus
onChange=(action "updateMarkdown")
onEditorInit=(action "setEditor")
onEditorDestroy=(action "destroyEditor")
options=simpleMDEOptions)
focus=(action "focusEditor")
)}}

View File

@ -0,0 +1,5 @@
<div class="gh-progress-container">
<div class="gh-progress-container-progress">
<div class="gh-progress-bar {{if isError "-error"}}" style={{progressStyle}}></div>
</div>
</div>

View File

@ -0,0 +1 @@
{{yield}}

View File

@ -0,0 +1,10 @@
{{#if hasBlock}}
{{yield (hash
progressBar=(component "gh-progress-bar" percentage=uploadPercentage)
files=files
errors=errors
cancel=(action "cancel")
)}}
{{else}}
{{!-- TODO: default uploader interface --}}
{{/if}}

View File

@ -1,5 +1,11 @@
{{#gh-editor tagName="section" class="gh-view" navIsClosed=navIsClosed as |headerClass|}} {{#gh-editor
<header class="gh-editor-header {{headerClass}}"> tagName="section"
class="gh-editor gh-view"
navIsClosed=navIsClosed
toggleAutoNav=(action "toggleAutoNav")
as |editor|
}}
<header class="gh-editor-header {{editor.headerClass}}">
<div class="gh-editor-status"> <div class="gh-editor-status">
{{gh-editor-post-status {{gh-editor-post-status
post=model post=model
@ -24,40 +30,83 @@
</section> </section>
</header> </header>
<div class="gh-editor-container needsclick"> {{!--
<div class="gh-editor-inner"> NOTE: title is part of the markdown editor container so that it has
{{!-- access to the markdown editor's "focus" action
NOTE: the mobiledoc property is unbound so that the setting the --}}
serialized version onChange doesn't cause a deserialization and {{#gh-markdown-editor
re-render of the editor on every key press / editor change class="gh-editor-container"
--}} tabindex="2"
{{#gh-koenig placeholder="Click here to start..."
mobiledoc=(unbound model.scratch) autofocus=shouldFocusEditor
onChange=(action "updateScratch") uploadedImageUrls=editor.uploadedImageUrls
onFirstChange=(action "autoSaveNew") mobiledoc=(readonly model.scratch)
autofocus=shouldFocusEditor onChange=(action "updateScratch")
tabindex="2" onFullScreen=(action editor.toggleFullScreen)
titleSelector="#kg-title-input" showMarkdownHelp=(route-action "toggleMarkdownHelpModal")
containerSelector=".gh-editor-container" as |markdown|
wordcountDidChange=(action "setWordcount") }}
as |koenig| {{gh-trim-focus-input model.titleScratch
}} type="text"
{{koenig-title-input class="gh-editor-title"
id="koenig-title-input" placeholder="Your Post Title"
val=(readonly model.titleScratch) tabindex="1"
onChange=(action (mut model.titleScratch)) shouldFocus=shouldFocusTitle
tabindex="1" focus-out="updateTitle"
autofocus=shouldFocusTitle update=(action (perform updateTitle))
focus-out="updateTitle" keyEvents=(hash
update=(action (perform updateTitle)) 9=(action markdown.focus 'bottom')
editor=(readonly koenig.editor) 13=(action markdown.focus 'top')
editorHasRendered=koenig.hasRendered )
editorMenuIsOpen=koenig.isMenuOpen data-test-editor-title-input=true
}} }}
{{/gh-koenig}}
{{markdown.pane}}
{{/gh-markdown-editor}}
{{!-- TODO: put tool/status bar in here so that scroll area can be fixed --}}
<footer class="gh-editor-footer"></footer>
{{!-- files are dragged over editor pane --}}
{{#if editor.isDraggedOver}}
<div class="drop-target gh-editor-drop-target">
<div class="drop-target-message">
<h3>Drop image(s) here to upload</h3>
</div>
</div> </div>
</div> {{/if}}
<div class="gh-editor-wordcount">{{pluralize wordcount 'word'}}.</div>
{{!-- files have been dropped ready to be uploaded --}}
{{#if editor.droppedFiles}}
{{#gh-uploader
files=editor.droppedFiles
accept=editor.imageMimeTypes
extensions=editor.imageExtensions
onComplete=(action editor.uploadComplete)
onCancel=(action editor.uploadCancelled)
as |upload|
}}
<div class="gh-editor-image-upload {{if upload.errors "-error"}}">
<div class="gh-editor-image-upload-content">
{{#if upload.errors}}
<h3>Upload failed</h3>
{{#each upload.errors as |error|}}
<div class="failed">{{error.fileName}} - {{error.message}}</div>
{{/each}}
<button class="gh-btn gh-btn-grey gh-btn-icon" {{action upload.cancel}}>
<span>{{inline-svg "close"}} Close</span>
</button>
{{else}}
<h3>Uploading {{pluralize upload.files.length "image"}}...</h3>
{{upload.progressBar}}
{{/if}}
</div>
</div>
{{/gh-uploader}}
{{/if}}
{{/gh-editor}} {{/gh-editor}}
{{#if showDeletePostModal}} {{#if showDeletePostModal}}

View File

@ -132,11 +132,12 @@ module.exports = function (defaults) {
'jquery-deparam': { 'jquery-deparam': {
import: ['jquery-deparam.js'] import: ['jquery-deparam.js']
}, },
'mobiledoc-kit': {
import: ['dist/amd/mobiledoc-kit.js', 'dist/amd/mobiledoc-kit.map']
},
'password-generator': { 'password-generator': {
import: ['lib/password-generator.js'] import: ['lib/password-generator.js']
},
'simplemde': {
srcDir: 'dist',
import: ['simplemde.min.js', 'simplemde.min.css']
} }
}, },
'ember-cli-selectize': { 'ember-cli-selectize': {

View File

@ -98,19 +98,18 @@
"liquid-wormhole": "2.0.4", "liquid-wormhole": "2.0.4",
"loader.js": "4.2.3", "loader.js": "4.2.3",
"matchdep": "1.0.1", "matchdep": "1.0.1",
"mobiledoc-kit": "0.10.15",
"password-generator": "2.1.0", "password-generator": "2.1.0",
"postcss-color-function": "3.0.0", "postcss-color-function": "3.0.0",
"postcss-custom-properties": "5.0.2", "postcss-custom-properties": "5.0.2",
"postcss-easy-import": "2.0.0", "postcss-easy-import": "2.0.0",
"simplemde": "1.11.2",
"top-gh-contribs": "2.0.4", "top-gh-contribs": "2.0.4",
"torii": "0.8.2", "torii": "0.8.2",
"walk-sync": "0.3.1" "walk-sync": "0.3.1"
}, },
"ember-addon": { "ember-addon": {
"paths": [ "paths": [
"lib/asset-delivery", "lib/asset-delivery"
"lib/gh-koenig"
] ]
}, },
"greenkeeper": { "greenkeeper": {

View File

@ -12,7 +12,6 @@ import {invalidateSession, authenticateSession} from 'ghost-admin/tests/helpers/
import Mirage from 'ember-cli-mirage'; import Mirage from 'ember-cli-mirage';
import sinon from 'sinon'; import sinon from 'sinon';
import testSelector from 'ember-test-selectors'; import testSelector from 'ember-test-selectors';
import {titleRendered, replaceTitleHTML} from '../helpers/editor-helpers';
import moment from 'moment'; import moment from 'moment';
describe('Acceptance: Editor', function() { describe('Acceptance: Editor', function() {
@ -326,11 +325,7 @@ describe('Acceptance: Editor', function() {
expect(currentURL(), 'currentURL') expect(currentURL(), 'currentURL')
.to.equal('/editor/1'); .to.equal('/editor/1');
titleRendered(); await fillIn(testSelector('editor-title-input'), Array(160).join('a'));
let title = find('#koenig-title-input div');
title.html(Array(160).join('a'));
await click(testSelector('publishmenu-trigger')); await click(testSelector('publishmenu-trigger'));
await click(testSelector('publishmenu-save')); await click(testSelector('publishmenu-save'));
@ -345,43 +340,44 @@ describe('Acceptance: Editor', function() {
).to.match(/Title cannot be longer than 150 characters/); ).to.match(/Title cannot be longer than 150 characters/);
}); });
it('inserts a placeholder if the title is blank', async function () { // NOTE: these tests are specific to the mobiledoc editor
server.createList('post', 1); // it('inserts a placeholder if the title is blank', async function () {
// server.createList('post', 1);
// post id 1 is a draft, checking for draft behaviour now //
await visit('/editor/1'); // // post id 1 is a draft, checking for draft behaviour now
// await visit('/editor/1');
expect(currentURL(), 'currentURL') //
.to.equal('/editor/1'); // expect(currentURL(), 'currentURL')
// .to.equal('/editor/1');
titleRendered(); //
// titleRendered();
let title = find('#koenig-title-input div'); //
expect(title.data('placeholder')).to.equal('Your Post Title'); // let title = find('#koenig-title-input div');
expect(title.hasClass('no-content')).to.be.false; // expect(title.data('placeholder')).to.equal('Your Post Title');
await title.html(''); // expect(title.hasClass('no-content')).to.be.false;
// await title.html('');
expect(title.hasClass('no-content')).to.be.true; //
await title.html('test'); // expect(title.hasClass('no-content')).to.be.true;
// await title.html('test');
expect(title.hasClass('no-content')).to.be.false; //
}); // expect(title.hasClass('no-content')).to.be.false;
// });
it('removes HTML from the title.', async function () { //
server.createList('post', 1); // it('removes HTML from the title.', async function () {
// server.createList('post', 1);
// post id 1 is a draft, checking for draft behaviour now //
await visit('/editor/1'); // // post id 1 is a draft, checking for draft behaviour now
// await visit('/editor/1');
expect(currentURL(), 'currentURL') //
.to.equal('/editor/1'); // expect(currentURL(), 'currentURL')
// .to.equal('/editor/1');
titleRendered(); //
// titleRendered();
let title = find('#koenig-title-input div'); //
await replaceTitleHTML('<div>TITLE&nbsp;&#09;&nbsp;&thinsp;&ensp;&emsp;TEST</div>&nbsp;'); // let title = find('#koenig-title-input div');
expect(title.html()).to.equal('TITLE TEST '); // await replaceTitleHTML('<div>TITLE&nbsp;&#09;&nbsp;&thinsp;&ensp;&emsp;TEST</div>&nbsp;');
}); // expect(title.html()).to.equal('TITLE TEST ');
// });
it('renders first countdown notification before scheduled time', async function () { it('renders first countdown notification before scheduled time', async function () {
let clock = sinon.useFakeTimers(moment().valueOf()); let clock = sinon.useFakeTimers(moment().valueOf());

View File

@ -0,0 +1,24 @@
import {expect} from 'chai';
import {describe, it} from 'mocha';
import {setupComponentTest} from 'ember-mocha';
import hbs from 'htmlbars-inline-precompile';
describe('Integration: Component: gh-markdown-editor', function() {
setupComponentTest('gh-markdown-editor', {
integration: true
});
it('renders', function() {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.on('myAction', function(val) { ... });
// Template block usage:
// this.render(hbs`
// {{#gh-markdown-editor}}
// template content
// {{/gh-markdown-editor}}
// `);
this.render(hbs`{{gh-markdown-editor}}`);
expect(this.$()).to.have.length(1);
});
});

View File

@ -0,0 +1,24 @@
import {expect} from 'chai';
import {describe, it} from 'mocha';
import {setupComponentTest} from 'ember-mocha';
import hbs from 'htmlbars-inline-precompile';
describe('Integration: Component: gh-progress-bar', function() {
setupComponentTest('gh-progress-bar', {
integration: true
});
it('renders', function() {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.on('myAction', function(val) { ... });
// Template block usage:
// this.render(hbs`
// {{#gh-progress-bar}}
// template content
// {{/gh-progress-bar}}
// `);
this.render(hbs`{{gh-progress-bar}}`);
expect(this.$()).to.have.length(1);
});
});

View File

@ -0,0 +1,24 @@
import {expect} from 'chai';
import {describe, it} from 'mocha';
import {setupComponentTest} from 'ember-mocha';
import hbs from 'htmlbars-inline-precompile';
describe('Integration: Component: gh-simplemde', function() {
setupComponentTest('gh-simplemde', {
integration: true
});
it('renders', function() {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.on('myAction', function(val) { ... });
// Template block usage:
// this.render(hbs`
// {{#gh-simplemde}}
// template content
// {{/gh-simplemde}}
// `);
this.render(hbs`{{gh-simplemde}}`);
expect(this.$()).to.have.length(1);
});
});

View File

@ -0,0 +1,24 @@
import {expect} from 'chai';
import {describe, it} from 'mocha';
import {setupComponentTest} from 'ember-mocha';
import hbs from 'htmlbars-inline-precompile';
describe('Integration: Component: gh-uploader', function() {
setupComponentTest('gh-uploader', {
integration: true
});
it('renders', function() {
// Set any properties with this.set('myProperty', 'value');
// Handle any actions with this.on('myAction', function(val) { ... });
// Template block usage:
// this.render(hbs`
// {{#gh-uploader}}
// template content
// {{/gh-uploader}}
// `);
this.render(hbs`{{gh-uploader}}`);
expect(this.$()).to.have.length(1);
});
});

View File

@ -1713,7 +1713,13 @@ code-point-at@^1.0.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77" resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
codemirror@5.25.0: codemirror-spell-checker@*:
version "1.1.2"
resolved "https://registry.yarnpkg.com/codemirror-spell-checker/-/codemirror-spell-checker-1.1.2.tgz#1c660f9089483ccb5113b9ba9ca19c3f4993371e"
dependencies:
typo-js "*"
codemirror@*, codemirror@5.25.0:
version "5.25.0" version "5.25.0"
resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.25.0.tgz#78e06939c7bb41f65707b8aa9c5328111948b756" resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.25.0.tgz#78e06939c7bb41f65707b8aa9c5328111948b756"
@ -5115,6 +5121,10 @@ markdown-it@^8.2.0:
mdurl "^1.0.1" mdurl "^1.0.1"
uc.micro "^1.0.3" uc.micro "^1.0.3"
marked@*:
version "0.3.6"
resolved "https://registry.yarnpkg.com/marked/-/marked-0.3.6.tgz#b2c6c618fccece4ef86c4fc6cb8a7cbf5aeda8d7"
match-media@^0.2.0: match-media@^0.2.0:
version "0.2.0" version "0.2.0"
resolved "https://registry.yarnpkg.com/match-media/-/match-media-0.2.0.tgz#ea4e09742e7253cc7d7e1599ba627e0fa29fbc50" resolved "https://registry.yarnpkg.com/match-media/-/match-media-0.2.0.tgz#ea4e09742e7253cc7d7e1599ba627e0fa29fbc50"
@ -5300,21 +5310,6 @@ mktemp@~0.4.0:
version "0.4.0" version "0.4.0"
resolved "https://registry.yarnpkg.com/mktemp/-/mktemp-0.4.0.tgz#6d0515611c8a8c84e484aa2000129b98e981ff0b" resolved "https://registry.yarnpkg.com/mktemp/-/mktemp-0.4.0.tgz#6d0515611c8a8c84e484aa2000129b98e981ff0b"
mobiledoc-dom-renderer@0.6.5:
version "0.6.5"
resolved "https://registry.yarnpkg.com/mobiledoc-dom-renderer/-/mobiledoc-dom-renderer-0.6.5.tgz#56c0302c4f9c30840ab5b9b20dfe905aed1e437b"
mobiledoc-kit@0.10.15:
version "0.10.15"
resolved "https://registry.yarnpkg.com/mobiledoc-kit/-/mobiledoc-kit-0.10.15.tgz#b4afb65febeafb65d7cef6ae9cd2e71b1de8c910"
dependencies:
mobiledoc-dom-renderer "0.6.5"
mobiledoc-text-renderer "0.3.2"
mobiledoc-text-renderer@0.3.2:
version "0.3.2"
resolved "https://registry.yarnpkg.com/mobiledoc-text-renderer/-/mobiledoc-text-renderer-0.3.2.tgz#126a167a6cf8b6cd7e58c85feb18043603834580"
mocha@^2.5.3: mocha@^2.5.3:
version "2.5.3" version "2.5.3"
resolved "https://registry.yarnpkg.com/mocha/-/mocha-2.5.3.tgz#161be5bdeb496771eb9b35745050b622b5aefc58" resolved "https://registry.yarnpkg.com/mocha/-/mocha-2.5.3.tgz#161be5bdeb496771eb9b35745050b622b5aefc58"
@ -6589,6 +6584,14 @@ simple-is@~0.2.0:
version "0.2.0" version "0.2.0"
resolved "https://registry.yarnpkg.com/simple-is/-/simple-is-0.2.0.tgz#2abb75aade39deb5cc815ce10e6191164850baf0" resolved "https://registry.yarnpkg.com/simple-is/-/simple-is-0.2.0.tgz#2abb75aade39deb5cc815ce10e6191164850baf0"
simplemde@1.11.2:
version "1.11.2"
resolved "https://registry.yarnpkg.com/simplemde/-/simplemde-1.11.2.tgz#a23a35d978d2c40ef07dec008c92f070d8e080e3"
dependencies:
codemirror "*"
codemirror-spell-checker "*"
marked "*"
sinon@^2.1.0: sinon@^2.1.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/sinon/-/sinon-2.1.0.tgz#e057a9d2bf1b32f5d6dd62628ca9ee3961b0cafb" resolved "https://registry.yarnpkg.com/sinon/-/sinon-2.1.0.tgz#e057a9d2bf1b32f5d6dd62628ca9ee3961b0cafb"
@ -7119,6 +7122,10 @@ typedarray@^0.0.6:
version "0.0.6" version "0.0.6"
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
typo-js@*:
version "1.0.3"
resolved "https://registry.yarnpkg.com/typo-js/-/typo-js-1.0.3.tgz#54d8ebc7949f1a7810908b6002c6841526c99d5a"
uc.micro@^1.0.0, uc.micro@^1.0.1, uc.micro@^1.0.3: uc.micro@^1.0.0, uc.micro@^1.0.1, uc.micro@^1.0.3:
version "1.0.3" version "1.0.3"
resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.3.tgz#7ed50d5e0f9a9fb0a573379259f2a77458d50192" resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.3.tgz#7ed50d5e0f9a9fb0a573379259f2a77458d50192"