mirror of
https://github.com/TryGhost/Ghost-Admin.git
synced 2023-12-14 02:33:04 +01:00
Reach Editor parity with Ember
closes #2426, closes #2781, closes #2913 - Concatenate vendor files on change of js in core/shared/ - Add all the markerManager stuff to its own mixin - make markers a shared object for all that mix it in. makes it easier to use helper functions in different modules - add getMarkdown method, returns object with two keys holding the markdown: one with markers, the other without - Clear markers when codemirror is destroyed - make Editor subcomponents communicate through the Editor Controller - Set Codemirror and html preview shared scrolling - Set CodeMirror, html preview css scroll class with util - Create 'scratch' property in Editor controller; prevents a model save wiping image markers due to markdown bindings - Add editor and html preview actions to handle img upload start/finish - disable codemirror when an image is being uploaded, enables on success or failure - Fix editor wordcount when there are 0 words - Add modal dialog when transitioning out of the editor with an unsaved post - Add window.onbeforeunload handling with `.unloadDirtyMessage()` on editor controller - and various other things
This commit is contained in:
parent
175194fcff
commit
445cd9e727
|
@ -1,34 +1,99 @@
|
||||||
/* global CodeMirror*/
|
/* global CodeMirror*/
|
||||||
|
import MarkerManager from 'ghost/mixins/marker-manager';
|
||||||
|
import setScrollClassName from 'ghost/utils/set-scroll-classname';
|
||||||
|
|
||||||
|
var onChangeHandler = function (cm, changeObj) {
|
||||||
|
var line,
|
||||||
|
component = cm.component,
|
||||||
|
checkLine = component.checkLine.bind(component),
|
||||||
|
checkMarkers = component.checkMarkers.bind(component);
|
||||||
|
|
||||||
|
// fill array with a range of numbers
|
||||||
|
for (line = changeObj.from.line; line < changeObj.from.line + changeObj.text.length; line += 1) {
|
||||||
|
checkLine(line, changeObj.origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is this a line which may have had a marker on it?
|
||||||
|
checkMarkers();
|
||||||
|
|
||||||
var onChangeHandler = function (cm) {
|
|
||||||
cm.component.set('value', cm.getDoc().getValue());
|
cm.component.set('value', cm.getDoc().getValue());
|
||||||
};
|
};
|
||||||
|
|
||||||
var onScrollHandler = function (cm) {
|
var onScrollHandler = function (cm) {
|
||||||
var scrollInfo = cm.getScrollInfo(),
|
var scrollInfo = cm.getScrollInfo(),
|
||||||
percentage = scrollInfo.top / scrollInfo.height,
|
|
||||||
component = cm.component;
|
component = cm.component;
|
||||||
|
|
||||||
|
scrollInfo.codemirror = cm;
|
||||||
|
|
||||||
// throttle scroll updates
|
// throttle scroll updates
|
||||||
component.throttle = Ember.run.throttle(component, function () {
|
component.throttle = Ember.run.throttle(component, function () {
|
||||||
this.set('scrollPosition', percentage);
|
this.set('scrollInfo', scrollInfo);
|
||||||
}, 50);
|
}, 10);
|
||||||
};
|
};
|
||||||
|
|
||||||
var Codemirror = Ember.TextArea.extend({
|
var Codemirror = Ember.TextArea.extend(MarkerManager, {
|
||||||
|
didInsertElement: function () {
|
||||||
|
Ember.run.scheduleOnce('afterRender', this, this.afterRenderEvent);
|
||||||
|
},
|
||||||
|
|
||||||
|
afterRenderEvent: function () {
|
||||||
|
var initMarkers = this.initMarkers.bind(this);
|
||||||
|
|
||||||
|
this.initCodemirror();
|
||||||
|
this.codemirror.eachLine(initMarkers);
|
||||||
|
this.sendAction('action', this);
|
||||||
|
},
|
||||||
|
|
||||||
|
// this needs to be placed on the 'afterRender' queue otherwise CodeMirror gets wonky
|
||||||
initCodemirror: function () {
|
initCodemirror: function () {
|
||||||
// create codemirror
|
// create codemirror
|
||||||
this.codemirror = CodeMirror.fromTextArea(this.get('element'), {
|
var codemirror = CodeMirror.fromTextArea(this.get('element'), {
|
||||||
lineWrapping: true
|
mode: 'gfm',
|
||||||
|
tabMode: 'indent',
|
||||||
|
tabindex: '2',
|
||||||
|
cursorScrollMargin: 10,
|
||||||
|
lineWrapping: true,
|
||||||
|
dragDrop: false,
|
||||||
|
extraKeys: {
|
||||||
|
Home: 'goLineLeft',
|
||||||
|
End: 'goLineRight'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
this.codemirror.component = this; // save reference to this
|
|
||||||
|
codemirror.component = this; // save reference to this
|
||||||
|
|
||||||
// propagate changes to value property
|
// propagate changes to value property
|
||||||
this.codemirror.on('change', onChangeHandler);
|
codemirror.on('change', onChangeHandler);
|
||||||
|
|
||||||
// on scroll update scrollPosition property
|
// on scroll update scrollPosition property
|
||||||
this.codemirror.on('scroll', onScrollHandler);
|
codemirror.on('scroll', onScrollHandler);
|
||||||
}.on('didInsertElement'),
|
|
||||||
|
codemirror.on('scroll', Ember.run.bind(Ember.$('.CodeMirror-scroll'), setScrollClassName, {
|
||||||
|
target: Ember.$('.entry-markdown'),
|
||||||
|
offset: 10
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.set('codemirror', codemirror);
|
||||||
|
},
|
||||||
|
|
||||||
|
disableCodeMirror: function () {
|
||||||
|
var codemirror = this.get('codemirror');
|
||||||
|
|
||||||
|
codemirror.setOption('readOnly', 'nocursor');
|
||||||
|
codemirror.off('change', onChangeHandler);
|
||||||
|
},
|
||||||
|
|
||||||
|
enableCodeMirror: function () {
|
||||||
|
var codemirror = this.get('codemirror');
|
||||||
|
|
||||||
|
codemirror.setOption('readOnly', false);
|
||||||
|
|
||||||
|
// clicking the trash button on an image dropzone causes this function to fire.
|
||||||
|
// this line is a hack to prevent multiple event handlers from being attached.
|
||||||
|
codemirror.off('change', onChangeHandler);
|
||||||
|
|
||||||
|
codemirror.on('change', onChangeHandler);
|
||||||
|
},
|
||||||
|
|
||||||
removeThrottle: function () {
|
removeThrottle: function () {
|
||||||
Ember.run.cancel(this.throttle);
|
Ember.run.cancel(this.throttle);
|
||||||
|
@ -36,8 +101,13 @@ var Codemirror = Ember.TextArea.extend({
|
||||||
|
|
||||||
removeCodemirrorHandlers: function () {
|
removeCodemirrorHandlers: function () {
|
||||||
// not sure if this is needed.
|
// not sure if this is needed.
|
||||||
this.codemirror.off('change', onChangeHandler);
|
var codemirror = this.get('codemirror');
|
||||||
this.codemirror.off('scroll', onScrollHandler);
|
codemirror.off('change', onChangeHandler);
|
||||||
|
codemirror.off('scroll');
|
||||||
|
}.on('willDestroyElement'),
|
||||||
|
|
||||||
|
clearMarkerManagerMarkers: function () {
|
||||||
|
this.clearMarkers();
|
||||||
}.on('willDestroyElement')
|
}.on('willDestroyElement')
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,36 @@
|
||||||
var Markdown = Ember.Component.extend({
|
import uploader from 'ghost/assets/lib/uploader';
|
||||||
adjustScrollPosition: function () {
|
|
||||||
var scrollWrapper = this.$().closest('.entry-preview-content').get(0),
|
|
||||||
// calculate absolute scroll position from percentage
|
|
||||||
scrollPixel = scrollWrapper.scrollHeight * this.get('scrollPosition');
|
|
||||||
|
|
||||||
scrollWrapper.scrollTop = scrollPixel; // adjust scroll position
|
var Markdown = Ember.Component.extend({
|
||||||
}.observes('scrollPosition')
|
classNames: ['rendered-markdown'],
|
||||||
|
|
||||||
|
didInsertElement: function () {
|
||||||
|
this.set('scrollWrapper', this.$().closest('.entry-preview-content'));
|
||||||
|
},
|
||||||
|
|
||||||
|
adjustScrollPosition: function () {
|
||||||
|
var scrollWrapper = this.get('scrollWrapper'),
|
||||||
|
scrollPosition = this.get('scrollPosition');
|
||||||
|
|
||||||
|
scrollWrapper.scrollTop(scrollPosition);
|
||||||
|
}.observes('scrollPosition'),
|
||||||
|
|
||||||
|
// fire off 'enable' API function from uploadManager
|
||||||
|
// might need to make sure markdown has been processed first
|
||||||
|
reInitDropzones: function () {
|
||||||
|
Ember.run.scheduleOnce('afterRender', this, function () {
|
||||||
|
var dropzones = $('.js-drop-zone');
|
||||||
|
|
||||||
|
uploader.call(dropzones, {
|
||||||
|
editor: true,
|
||||||
|
filestorage: false
|
||||||
|
});
|
||||||
|
|
||||||
|
dropzones.on('uploadstart', this.sendAction.bind(this, 'uploadStarted'));
|
||||||
|
dropzones.on('uploadfailure', this.sendAction.bind(this, 'uploadFinished'));
|
||||||
|
dropzones.on('uploadsuccess', this.sendAction.bind(this, 'uploadFinished'));
|
||||||
|
dropzones.on('uploadsuccess', this.sendAction.bind(this, 'uploadSuccess'));
|
||||||
|
});
|
||||||
|
}.observes('markdown')
|
||||||
});
|
});
|
||||||
|
|
||||||
export default Markdown;
|
export default Markdown;
|
||||||
|
|
|
@ -1,5 +1,16 @@
|
||||||
import EditorControllerMixin from 'ghost/mixins/editor-base-controller';
|
import EditorControllerMixin from 'ghost/mixins/editor-base-controller';
|
||||||
|
import MarkerManager from 'ghost/mixins/marker-manager';
|
||||||
|
|
||||||
var EditorEditController = Ember.ObjectController.extend(EditorControllerMixin);
|
var EditorEditController = Ember.ObjectController.extend(EditorControllerMixin, MarkerManager, {
|
||||||
|
init: function () {
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
this._super();
|
||||||
|
|
||||||
|
window.onbeforeunload = function () {
|
||||||
|
return self.get('isDirty') ? self.unloadDirtyMessage() : null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default EditorEditController;
|
export default EditorEditController;
|
||||||
|
|
|
@ -1,6 +1,17 @@
|
||||||
import EditorControllerMixin from 'ghost/mixins/editor-base-controller';
|
import EditorControllerMixin from 'ghost/mixins/editor-base-controller';
|
||||||
|
import MarkerManager from 'ghost/mixins/marker-manager';
|
||||||
|
|
||||||
|
var EditorNewController = Ember.ObjectController.extend(EditorControllerMixin, MarkerManager, {
|
||||||
|
init: function () {
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
this._super();
|
||||||
|
|
||||||
|
window.onbeforeunload = function () {
|
||||||
|
return self.get('isDirty') ? self.unloadDirtyMessage() : null;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
var EditorNewController = Ember.ObjectController.extend(EditorControllerMixin, {
|
|
||||||
actions: {
|
actions: {
|
||||||
/**
|
/**
|
||||||
* Redirect to editor after the first save
|
* Redirect to editor after the first save
|
||||||
|
@ -17,4 +28,4 @@ var EditorNewController = Ember.ObjectController.extend(EditorControllerMixin, {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default EditorNewController;
|
export default EditorNewController;
|
||||||
|
|
59
controllers/modals/leave-editor.js
Normal file
59
controllers/modals/leave-editor.js
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
var LeaveEditorController = Ember.Controller.extend({
|
||||||
|
args: Ember.computed.alias('model'),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
confirmAccept: function () {
|
||||||
|
var args = this.get('args'),
|
||||||
|
editorController,
|
||||||
|
model,
|
||||||
|
transition;
|
||||||
|
|
||||||
|
if (Ember.isArray(args)) {
|
||||||
|
editorController = args[0];
|
||||||
|
transition = args[1];
|
||||||
|
model = editorController.get('model');
|
||||||
|
}
|
||||||
|
|
||||||
|
// @TODO: throw some kind of error here? return true will send it upward?
|
||||||
|
if (!transition || !editorController) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// definitely want to clear the data store and post of any unsaved, client-generated tags
|
||||||
|
editorController.updateTags();
|
||||||
|
|
||||||
|
if (model.get('isNew')) {
|
||||||
|
// the user doesn't want to save the new, unsaved post, so delete it.
|
||||||
|
model.deleteRecord();
|
||||||
|
} else {
|
||||||
|
// roll back changes on model props
|
||||||
|
model.rollback();
|
||||||
|
}
|
||||||
|
|
||||||
|
// setting isDirty to false here allows willTransition on the editor route to succeed
|
||||||
|
editorController.set('isDirty', false);
|
||||||
|
|
||||||
|
// since the transition is now certain to complete, we can unset window.onbeforeunload here
|
||||||
|
window.onbeforeunload = null;
|
||||||
|
|
||||||
|
transition.retry();
|
||||||
|
},
|
||||||
|
|
||||||
|
confirmReject: function () {
|
||||||
|
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
confirm: {
|
||||||
|
accept: {
|
||||||
|
text: 'Leave',
|
||||||
|
buttonClass: 'button-delete'
|
||||||
|
},
|
||||||
|
reject: {
|
||||||
|
text: 'Cancel',
|
||||||
|
buttonClass: 'button'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default LeaveEditorController;
|
|
@ -1,7 +1,12 @@
|
||||||
import count from 'ghost/utils/word-count';
|
import counter from 'ghost/utils/word-count';
|
||||||
|
|
||||||
var countWords = Ember.Handlebars.makeBoundHelper(function (markdown) {
|
var countWords = Ember.Handlebars.makeBoundHelper(function (markdown) {
|
||||||
return count(markdown || '');
|
if (/^\s*$/.test(markdown)) {
|
||||||
|
return '0 words';
|
||||||
|
}
|
||||||
|
|
||||||
|
var count = counter(markdown || '');
|
||||||
|
return count + (count === 1 ? ' word' : ' words');
|
||||||
});
|
});
|
||||||
|
|
||||||
export default countWords;
|
export default countWords;
|
|
@ -1,5 +1,5 @@
|
||||||
/* global Showdown, Handlebars */
|
/* global Showdown, Handlebars */
|
||||||
var showdown = new Showdown.converter();
|
var showdown = new Showdown.converter({extensions: ['ghostimagepreview', 'ghostgfm']});
|
||||||
|
|
||||||
var formatMarkdown = Ember.Handlebars.makeBoundHelper(function (markdown) {
|
var formatMarkdown = Ember.Handlebars.makeBoundHelper(function (markdown) {
|
||||||
return new Handlebars.SafeString(showdown.makeHtml(markdown || ''));
|
return new Handlebars.SafeString(showdown.makeHtml(markdown || ''));
|
||||||
|
|
|
@ -1,6 +1,19 @@
|
||||||
/* global console */
|
/* global console */
|
||||||
|
import MarkerManager from 'ghost/mixins/marker-manager';
|
||||||
|
import PostModel from 'ghost/models/post';
|
||||||
|
|
||||||
var EditorControllerMixin = Ember.Mixin.create({
|
// this array will hold properties we need to watch
|
||||||
|
// to know if the model has been changed (`controller.isDirty`)
|
||||||
|
var watchedProps = ['scratch', 'model.isDirty'];
|
||||||
|
|
||||||
|
Ember.get(PostModel, 'attributes').forEach(function (name) {
|
||||||
|
watchedProps.push('model.' + name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// watch if number of tags changes on the model
|
||||||
|
watchedProps.push('tags.[]');
|
||||||
|
|
||||||
|
var EditorControllerMixin = Ember.Mixin.create(MarkerManager, {
|
||||||
/**
|
/**
|
||||||
* By default, a post will not change its publish state.
|
* By default, a post will not change its publish state.
|
||||||
* Only with a user-set value (via setSaveType action)
|
* Only with a user-set value (via setSaveType action)
|
||||||
|
@ -13,6 +26,90 @@ var EditorControllerMixin = Ember.Mixin.create({
|
||||||
return this.get('isPublished');
|
return this.get('isPublished');
|
||||||
}.property('isPublished'),
|
}.property('isPublished'),
|
||||||
|
|
||||||
|
// set by the editor route and `isDirty`. useful when checking
|
||||||
|
// whether the number of tags has changed for `isDirty`.
|
||||||
|
previousTagNames: null,
|
||||||
|
|
||||||
|
tagNames: function () {
|
||||||
|
return this.get('tags').mapBy('name');
|
||||||
|
}.property('tags.[]'),
|
||||||
|
|
||||||
|
// compares previousTagNames to tagNames
|
||||||
|
tagNamesEqual: function () {
|
||||||
|
var tagNames = this.get('tagNames'),
|
||||||
|
previousTagNames = this.get('previousTagNames'),
|
||||||
|
hashCurrent,
|
||||||
|
hashPrevious;
|
||||||
|
|
||||||
|
// beware! even if they have the same length,
|
||||||
|
// that doesn't mean they're the same.
|
||||||
|
if (tagNames.length !== previousTagNames.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// instead of comparing with slow, nested for loops,
|
||||||
|
// perform join on each array and compare the strings
|
||||||
|
hashCurrent = tagNames.join('');
|
||||||
|
hashPrevious = previousTagNames.join('');
|
||||||
|
|
||||||
|
return hashCurrent === hashPrevious;
|
||||||
|
},
|
||||||
|
|
||||||
|
// an ugly hack, but necessary to watch all the model's properties
|
||||||
|
// and more, without having to be explicit and do it manually
|
||||||
|
isDirty: Ember.computed.apply(Ember, watchedProps.concat(function (key, value) {
|
||||||
|
if (arguments.length > 1) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var model = this.get('model'),
|
||||||
|
markdown = this.get('markdown'),
|
||||||
|
scratch = this.getMarkdown().withoutMarkers,
|
||||||
|
changedAttributes;
|
||||||
|
|
||||||
|
if (!this.tagNamesEqual()) {
|
||||||
|
this.set('previousTagNames', this.get('tagNames'));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// since `scratch` is not model property, we need to check
|
||||||
|
// it explicitly against the model's markdown attribute
|
||||||
|
if (markdown !== scratch) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// models created on the client always return `isDirty: true`,
|
||||||
|
// so we need to see which properties have actually changed.
|
||||||
|
if (model.get('isNew')) {
|
||||||
|
changedAttributes = Ember.keys(model.changedAttributes());
|
||||||
|
|
||||||
|
if (changedAttributes.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// even though we use the `scratch` prop to show edits,
|
||||||
|
// which does *not* change the model's `isDirty` property,
|
||||||
|
// `isDirty` will tell us if the other props have changed,
|
||||||
|
// as long as the model is not new (model.isNew === false).
|
||||||
|
if (model.get('isDirty')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
})),
|
||||||
|
|
||||||
|
// used on window.onbeforeunload
|
||||||
|
unloadDirtyMessage: function () {
|
||||||
|
return '==============================\n\n' +
|
||||||
|
'Hey there! It looks like you\'re in the middle of writing' +
|
||||||
|
' something and you haven\'t saved all of your content.' +
|
||||||
|
'\n\nSave before you go!\n\n' +
|
||||||
|
'==============================';
|
||||||
|
},
|
||||||
|
|
||||||
// remove client-generated tags, which have `id: null`.
|
// remove client-generated tags, which have `id: null`.
|
||||||
// Ember Data won't recognize/update them automatically
|
// Ember Data won't recognize/update them automatically
|
||||||
// when returned from the server with ids.
|
// when returned from the server with ids.
|
||||||
|
@ -29,9 +126,15 @@ var EditorControllerMixin = Ember.Mixin.create({
|
||||||
var status = this.get('willPublish') ? 'published' : 'draft',
|
var status = this.get('willPublish') ? 'published' : 'draft',
|
||||||
self = this;
|
self = this;
|
||||||
|
|
||||||
|
// set markdown equal to what's in the editor, minus the image markers.
|
||||||
|
this.set('markdown', this.getMarkdown().withoutMarkers);
|
||||||
|
|
||||||
this.set('status', status);
|
this.set('status', status);
|
||||||
return this.get('model').save().then(function (model) {
|
return this.get('model').save().then(function (model) {
|
||||||
self.updateTags();
|
self.updateTags();
|
||||||
|
// `updateTags` triggers `isDirty => true`.
|
||||||
|
// for a saved model it would otherwise be false.
|
||||||
|
self.set('isDirty', false);
|
||||||
|
|
||||||
self.notifications.showSuccess('Post status saved as <strong>' +
|
self.notifications.showSuccess('Post status saved as <strong>' +
|
||||||
model.get('status') + '</strong>.');
|
model.get('status') + '</strong>.');
|
||||||
|
@ -47,6 +150,57 @@ var EditorControllerMixin = Ember.Mixin.create({
|
||||||
} else {
|
} else {
|
||||||
console.warn('Received invalid save type; ignoring.');
|
console.warn('Received invalid save type; ignoring.');
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// set from a `sendAction` on the codemirror component,
|
||||||
|
// so that we get a reference for handling uploads.
|
||||||
|
setCodeMirror: function (codemirrorComponent) {
|
||||||
|
var codemirror = codemirrorComponent.get('codemirror');
|
||||||
|
|
||||||
|
this.set('codemirrorComponent', codemirrorComponent);
|
||||||
|
this.set('codemirror', codemirror);
|
||||||
|
},
|
||||||
|
|
||||||
|
// fired from the gh-markdown component when an image upload starts
|
||||||
|
disableCodeMirror: function () {
|
||||||
|
this.get('codemirrorComponent').disableCodeMirror();
|
||||||
|
},
|
||||||
|
|
||||||
|
// fired from the gh-markdown component when an image upload finishes
|
||||||
|
enableCodeMirror: function () {
|
||||||
|
this.get('codemirrorComponent').enableCodeMirror();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Match the uploaded file to a line in the editor, and update that line with a path reference
|
||||||
|
// ensuring that everything ends up in the correct place and format.
|
||||||
|
handleImgUpload: function (e, result_src) {
|
||||||
|
var editor = this.get('codemirror'),
|
||||||
|
line = this.findLine(Ember.$(e.currentTarget).attr('id')),
|
||||||
|
lineNumber = editor.getLineNumber(line),
|
||||||
|
match = line.text.match(/\([^\n]*\)?/),
|
||||||
|
replacement = '(http://)';
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
// simple case, we have the parenthesis
|
||||||
|
editor.setSelection(
|
||||||
|
{line: lineNumber, ch: match.index + 1},
|
||||||
|
{line: lineNumber, ch: match.index + match[0].length - 1}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
match = line.text.match(/\]/);
|
||||||
|
if (match) {
|
||||||
|
editor.replaceRange(
|
||||||
|
replacement,
|
||||||
|
{line: lineNumber, ch: match.index + 1},
|
||||||
|
{line: lineNumber, ch: match.index + 1}
|
||||||
|
);
|
||||||
|
editor.setSelection(
|
||||||
|
{line: lineNumber, ch: match.index + 2},
|
||||||
|
{line: lineNumber, ch: match.index + replacement.length }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editor.replaceSelection(result_src);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
58
mixins/editor-base-view.js
Normal file
58
mixins/editor-base-view.js
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import setScrollClassName from 'ghost/utils/set-scroll-classname';
|
||||||
|
|
||||||
|
var EditorViewMixin = Ember.Mixin.create({
|
||||||
|
// create a hook for jQuery logic that will run after
|
||||||
|
// a view and all child views have been rendered,
|
||||||
|
// since didInsertElement runs only when the view's el
|
||||||
|
// has rendered, and not necessarily all child views.
|
||||||
|
//
|
||||||
|
// http://mavilein.github.io/javascript/2013/08/01/Ember-JS-After-Render-Event/
|
||||||
|
// http://emberjs.com/api/classes/Ember.run.html#method_next
|
||||||
|
scheduleAfterRender: function () {
|
||||||
|
Ember.run.scheduleOnce('afterRender', this, this.afterRenderEvent);
|
||||||
|
}.on('didInsertElement'),
|
||||||
|
|
||||||
|
// all child views will have rendered when this fires
|
||||||
|
afterRenderEvent: function () {
|
||||||
|
var $previewViewPort = this.$('.entry-preview-content');
|
||||||
|
|
||||||
|
// cache these elements for use in other methods
|
||||||
|
this.set('$previewViewPort', $previewViewPort);
|
||||||
|
this.set('$previewContent', this.$('.rendered-markdown'));
|
||||||
|
|
||||||
|
$previewViewPort.scroll(Ember.run.bind($previewViewPort, setScrollClassName, {
|
||||||
|
target: this.$('.entry-preview'),
|
||||||
|
offset: 10
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
removeScrollHandlers: function () {
|
||||||
|
this.get('$previewViewPort').off('scroll');
|
||||||
|
}.on('willDestroyElement'),
|
||||||
|
|
||||||
|
// updated when gh-codemirror component scrolls
|
||||||
|
markdownScrollInfo: null,
|
||||||
|
|
||||||
|
// percentage of scroll position to set htmlPreview
|
||||||
|
scrollPosition: Ember.computed('markdownScrollInfo', function () {
|
||||||
|
if (!this.get('markdownScrollInfo')) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var scrollInfo = this.get('markdownScrollInfo'),
|
||||||
|
codemirror = scrollInfo.codemirror,
|
||||||
|
markdownHeight = scrollInfo.height - scrollInfo.clientHeight,
|
||||||
|
previewHeight = this.get('$previewContent').height() - this.get('$previewViewPort').height(),
|
||||||
|
ratio = previewHeight / markdownHeight,
|
||||||
|
previewPosition = scrollInfo.top * ratio,
|
||||||
|
isCursorAtEnd = codemirror.getCursor('end').line > codemirror.lineCount() - 5;
|
||||||
|
|
||||||
|
if (isCursorAtEnd) {
|
||||||
|
previewPosition = previewHeight + 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
return previewPosition;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export default EditorViewMixin;
|
219
mixins/marker-manager.js
Normal file
219
mixins/marker-manager.js
Normal file
|
@ -0,0 +1,219 @@
|
||||||
|
var MarkerManager = Ember.Mixin.create({
|
||||||
|
imageMarkdownRegex: /^(?:\{<(.*?)>\})?!(?:\[([^\n\]]*)\])(?:\(([^\n\]]*)\))?$/gim,
|
||||||
|
markerRegex: /\{<([\w\W]*?)>\}/,
|
||||||
|
|
||||||
|
uploadId: 1,
|
||||||
|
|
||||||
|
// create an object that will be shared amongst instances.
|
||||||
|
// makes it easier to use helper functions in different modules
|
||||||
|
markers: {},
|
||||||
|
|
||||||
|
// Add markers to the line if it needs one
|
||||||
|
initMarkers: function (line) {
|
||||||
|
var imageMarkdownRegex = this.get('imageMarkdownRegex'),
|
||||||
|
markerRegex = this.get('markerRegex'),
|
||||||
|
editor = this.get('codemirror'),
|
||||||
|
isImage = line.text.match(imageMarkdownRegex),
|
||||||
|
hasMarker = line.text.match(markerRegex);
|
||||||
|
|
||||||
|
if (isImage && !hasMarker) {
|
||||||
|
this.addMarker(line, editor.getLineNumber(line));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get the markdown with all the markers stripped
|
||||||
|
getMarkdown: function (value) {
|
||||||
|
var marker, id,
|
||||||
|
editor = this.get('codemirror'),
|
||||||
|
markers = this.get('markers'),
|
||||||
|
markerRegexForId = this.get('markerRegexForId'),
|
||||||
|
oldValue = value || editor.getValue(),
|
||||||
|
newValue = oldValue;
|
||||||
|
|
||||||
|
for (id in markers) {
|
||||||
|
if (markers.hasOwnProperty(id)) {
|
||||||
|
marker = markers[id];
|
||||||
|
newValue = newValue.replace(markerRegexForId(id), '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
withMarkers: oldValue,
|
||||||
|
withoutMarkers: newValue
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// check the given line to see if it has an image, and if it correctly has a marker
|
||||||
|
// in the special case of lines which were just pasted in, any markers are removed to prevent duplication
|
||||||
|
checkLine: function (ln, mode) {
|
||||||
|
var editor = this.get('codemirror'),
|
||||||
|
line = editor.getLineHandle(ln),
|
||||||
|
imageMarkdownRegex = this.get('imageMarkdownRegex'),
|
||||||
|
markerRegex = this.get('markerRegex'),
|
||||||
|
isImage = line.text.match(imageMarkdownRegex),
|
||||||
|
hasMarker;
|
||||||
|
|
||||||
|
// We care if it is an image
|
||||||
|
if (isImage) {
|
||||||
|
hasMarker = line.text.match(markerRegex);
|
||||||
|
|
||||||
|
if (hasMarker && (mode === 'paste' || mode === 'undo')) {
|
||||||
|
// this could be a duplicate, and won't be a real marker
|
||||||
|
this.stripMarkerFromLine(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasMarker) {
|
||||||
|
this.addMarker(line, ln);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO: hasMarker but no image?
|
||||||
|
},
|
||||||
|
|
||||||
|
// Add a marker to the given line
|
||||||
|
// Params:
|
||||||
|
// line - CodeMirror LineHandle
|
||||||
|
// ln - line number
|
||||||
|
addMarker: function (line, ln) {
|
||||||
|
var marker,
|
||||||
|
markers = this.get('markers'),
|
||||||
|
editor = this.get('codemirror'),
|
||||||
|
uploadPrefix = 'image_upload',
|
||||||
|
uploadId = this.get('uploadId'),
|
||||||
|
magicId = '{<' + uploadId + '>}',
|
||||||
|
newText = magicId + line.text;
|
||||||
|
|
||||||
|
editor.replaceRange(
|
||||||
|
newText,
|
||||||
|
{line: ln, ch: 0},
|
||||||
|
{line: ln, ch: newText.length}
|
||||||
|
);
|
||||||
|
|
||||||
|
marker = editor.markText(
|
||||||
|
{line: ln, ch: 0},
|
||||||
|
{line: ln, ch: (magicId.length)},
|
||||||
|
{collapsed: true}
|
||||||
|
);
|
||||||
|
|
||||||
|
markers[uploadPrefix + '_' + uploadId] = marker;
|
||||||
|
this.set('uploadId', uploadId += 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Check each marker to see if it is still present in the editor and if it still corresponds to image markdown
|
||||||
|
// If it is no longer a valid image, remove it
|
||||||
|
checkMarkers: function () {
|
||||||
|
var id, marker, line,
|
||||||
|
editor = this.get('codemirror'),
|
||||||
|
markers = this.get('markers'),
|
||||||
|
imageMarkdownRegex = this.get('imageMarkdownRegex');
|
||||||
|
|
||||||
|
for (id in markers) {
|
||||||
|
if (markers.hasOwnProperty(id)) {
|
||||||
|
marker = markers[id];
|
||||||
|
|
||||||
|
if (marker.find()) {
|
||||||
|
line = editor.getLineHandle(marker.find().from.line);
|
||||||
|
if (!line.text.match(imageMarkdownRegex)) {
|
||||||
|
this.removeMarker(id, marker, line);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.removeMarker(id, marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// this is needed for when we transition out of the editor.
|
||||||
|
// since the markers object is persistent and shared between classes that
|
||||||
|
// mix in this mixin, we need to make sure markers don't carry over between edits.
|
||||||
|
clearMarkers: function () {
|
||||||
|
var markers = this.get('markers'),
|
||||||
|
id,
|
||||||
|
marker;
|
||||||
|
|
||||||
|
// can't just `this.set('markers', {})`,
|
||||||
|
// since it wouldn't apply to this mixin,
|
||||||
|
// but only to the class that mixed this mixin in
|
||||||
|
for (id in markers) {
|
||||||
|
if (markers.hasOwnProperty(id)) {
|
||||||
|
marker = markers[id];
|
||||||
|
delete markers[id];
|
||||||
|
marker.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Remove a marker
|
||||||
|
// Will be passed a LineHandle if we already know which line the marker is on
|
||||||
|
removeMarker: function (id, marker, line) {
|
||||||
|
var markers = this.get('markers');
|
||||||
|
|
||||||
|
delete markers[id];
|
||||||
|
marker.clear();
|
||||||
|
|
||||||
|
if (line) {
|
||||||
|
this.stripMarkerFromLine(line);
|
||||||
|
} else {
|
||||||
|
this.findAndStripMarker(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Removes the marker on the given line if there is one
|
||||||
|
stripMarkerFromLine: function (line) {
|
||||||
|
var ln,
|
||||||
|
editor = this.get('codemirror'),
|
||||||
|
markerRegex = /\{<([\w\W]*?)>\}/,
|
||||||
|
markerText = line.text.match(markerRegex);
|
||||||
|
|
||||||
|
ln = editor.getLineNumber(line);
|
||||||
|
|
||||||
|
if (markerText) {
|
||||||
|
editor.replaceRange(
|
||||||
|
'',
|
||||||
|
{line: ln, ch: markerText.index},
|
||||||
|
{line: ln, ch: markerText.index + markerText[0].length}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// the regex
|
||||||
|
markerRegexForId: function (id) {
|
||||||
|
id = id.replace('image_upload_', '');
|
||||||
|
return new RegExp('\\{<' + id + '>\\}', 'gmi');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Find a marker in the editor by id & remove it
|
||||||
|
// Goes line by line to find the marker by it's text if we've lost track of the TextMarker
|
||||||
|
findAndStripMarker: function (id) {
|
||||||
|
var self = this,
|
||||||
|
editor = this.get('codemirror');
|
||||||
|
|
||||||
|
editor.eachLine(function (line) {
|
||||||
|
var markerText = self.markerRegexForId(id).exec(line.text),
|
||||||
|
ln;
|
||||||
|
|
||||||
|
if (markerText) {
|
||||||
|
ln = editor.getLineNumber(line);
|
||||||
|
editor.replaceRange(
|
||||||
|
'',
|
||||||
|
{line: ln, ch: markerText.index},
|
||||||
|
{line: ln, ch: markerText.index + markerText[0].length}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Find the line with the marker which matches
|
||||||
|
findLine: function (result_id) {
|
||||||
|
var editor = this.get('codemirror'),
|
||||||
|
markers = this.get('markers');
|
||||||
|
|
||||||
|
// try to find the right line to replace
|
||||||
|
if (markers.hasOwnProperty(result_id) && markers[result_id].find()) {
|
||||||
|
return editor.getLineHandle(markers[result_id].find().from.line);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default MarkerManager;
|
|
@ -8,6 +8,7 @@ var EditorEditRoute = AuthenticatedRoute.extend(styleBody, {
|
||||||
var self = this,
|
var self = this,
|
||||||
post,
|
post,
|
||||||
postId;
|
postId;
|
||||||
|
|
||||||
postId = Number(params.post_id);
|
postId = Number(params.post_id);
|
||||||
|
|
||||||
if (!Number.isInteger(postId) || !Number.isFinite(postId) || postId <= 0) {
|
if (!Number.isInteger(postId) || !Number.isFinite(postId) || postId <= 0) {
|
||||||
|
@ -33,8 +34,42 @@ var EditorEditRoute = AuthenticatedRoute.extend(styleBody, {
|
||||||
return self.transitionTo('posts.index');
|
return self.transitionTo('posts.index');
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
serialize: function (model) {
|
serialize: function (model) {
|
||||||
return {post_id: model.get('id')};
|
return {post_id: model.get('id')};
|
||||||
|
},
|
||||||
|
|
||||||
|
setupController: function (controller, model) {
|
||||||
|
this._super(controller, model);
|
||||||
|
controller.set('scratch', model.get('markdown'));
|
||||||
|
|
||||||
|
model.get('tags').then(function (tags) {
|
||||||
|
// used to check if anything has changed in the editor
|
||||||
|
controller.set('previousTagNames', tags.mapBy('name'));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
willTransition: function (transition) {
|
||||||
|
var controller = this.get('controller'),
|
||||||
|
isDirty = controller.get('isDirty'),
|
||||||
|
|
||||||
|
model = controller.get('model'),
|
||||||
|
isSaving = model.get('isSaving'),
|
||||||
|
isDeleted = model.get('isDeleted');
|
||||||
|
|
||||||
|
// when `isDeleted && isSaving`, model is in-flight, being saved
|
||||||
|
// to the server. in that case we can probably just transition
|
||||||
|
// now and have the server return the record, thereby updating it
|
||||||
|
if (!(isDeleted && isSaving) && isDirty) {
|
||||||
|
transition.abort();
|
||||||
|
this.send('openModal', 'leave-editor', [controller, transition]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// since the transition is now certain to complete..
|
||||||
|
window.onbeforeunload = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -5,9 +5,43 @@ var EditorNewRoute = AuthenticatedRoute.extend(styleBody, {
|
||||||
classNames: ['editor'],
|
classNames: ['editor'],
|
||||||
|
|
||||||
model: function () {
|
model: function () {
|
||||||
return this.store.createRecord('post', {
|
return this.store.createRecord('post');
|
||||||
title: ''
|
},
|
||||||
});
|
|
||||||
|
setupController: function (controller, model) {
|
||||||
|
this._super(controller, model);
|
||||||
|
controller.set('scratch', '');
|
||||||
|
|
||||||
|
// used to check if anything has changed in the editor
|
||||||
|
controller.set('previousTagNames', Ember.A());
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
willTransition: function (transition) {
|
||||||
|
var controller = this.get('controller'),
|
||||||
|
isDirty = controller.get('isDirty'),
|
||||||
|
|
||||||
|
model = controller.get('model'),
|
||||||
|
isNew = model.get('isNew'),
|
||||||
|
isSaving = model.get('isSaving'),
|
||||||
|
isDeleted = model.get('isDeleted');
|
||||||
|
|
||||||
|
// when `isDeleted && isSaving`, model is in-flight, being saved
|
||||||
|
// to the server. in that case we can probably just transition
|
||||||
|
// now and have the server return the record, thereby updating it
|
||||||
|
if (!(isDeleted && isSaving) && isDirty) {
|
||||||
|
transition.abort();
|
||||||
|
this.send('openModal', 'leave-editor', [controller, transition]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
model.deleteRecord();
|
||||||
|
}
|
||||||
|
|
||||||
|
// since the transition is now certain to complete..
|
||||||
|
window.onbeforeunload = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1 @@
|
||||||
<div class="rendered-markdown">
|
{{gh-format-markdown markdown}}
|
||||||
{{gh-format-markdown markdown}}
|
|
||||||
</div>
|
|
||||||
|
|
|
@ -10,16 +10,17 @@
|
||||||
<a class="markdown-help" href="" {{action "openModal" "markdown"}}><span class="hidden">What is Markdown?</span></a>
|
<a class="markdown-help" href="" {{action "openModal" "markdown"}}><span class="hidden">What is Markdown?</span></a>
|
||||||
</header>
|
</header>
|
||||||
<section id="entry-markdown-content" class="entry-markdown-content">
|
<section id="entry-markdown-content" class="entry-markdown-content">
|
||||||
{{gh-codemirror value=markdown scrollPosition=view.scrollPosition}}
|
{{gh-codemirror value=scratch scrollInfo=view.markdownScrollInfo action="setCodeMirror"}}
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="entry-preview">
|
<section class="entry-preview">
|
||||||
<header class="floatingheader">
|
<header class="floatingheader">
|
||||||
<small>Preview <span class="entry-word-count js-entry-word-count">{{gh-count-words markdown}} words</span></small>
|
<small>Preview <span class="entry-word-count js-entry-word-count">{{gh-count-words scratch}}</span></small>
|
||||||
</header>
|
</header>
|
||||||
<section class="entry-preview-content">
|
<section class="entry-preview-content">
|
||||||
{{gh-markdown markdown=markdown scrollPosition=view.scrollPosition}}
|
{{gh-markdown markdown=scratch scrollPosition=view.scrollPosition
|
||||||
|
uploadStarted="disableCodeMirror" uploadFinished="enableCodeMirror" uploadSuccess="handleImgUpload"}}
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
9
templates/modals/leave-editor.hbs
Normal file
9
templates/modals/leave-editor.hbs
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
{{#gh-modal-dialog action="closeModal" showClose=true type="action" style="wide,centered" animation="fade"
|
||||||
|
title="Are you sure you want to leave this page?" confirm=confirm}}
|
||||||
|
|
||||||
|
<p>Hey there! It looks like you're in the middle of writing something and you haven't saved all of your
|
||||||
|
content.</p>
|
||||||
|
|
||||||
|
<p>Save before you go!</p>
|
||||||
|
|
||||||
|
{{/gh-modal-dialog}}
|
|
@ -1,7 +1,8 @@
|
||||||
var EditorView = Ember.View.extend({
|
import EditorViewMixin from 'ghost/mixins/editor-base-view';
|
||||||
|
|
||||||
|
var EditorView = Ember.View.extend(EditorViewMixin, {
|
||||||
tagName: 'section',
|
tagName: 'section',
|
||||||
classNames: ['entry-container'],
|
classNames: ['entry-container']
|
||||||
scrollPosition: 0 // percentage of scroll position
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default EditorView;
|
export default EditorView;
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
var EditorNewView = Ember.View.extend({
|
import EditorViewMixin from 'ghost/mixins/editor-base-view';
|
||||||
|
|
||||||
|
var EditorNewView = Ember.View.extend(EditorViewMixin, {
|
||||||
tagName: 'section',
|
tagName: 'section',
|
||||||
templateName: 'editor/edit',
|
templateName: 'editor/edit',
|
||||||
classNames: ['entry-container'],
|
classNames: ['entry-container']
|
||||||
scrollPosition: 0 // percentage of scroll position
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default EditorNewView;
|
export default EditorNewView;
|
||||||
|
|
Loading…
Reference in a new issue