mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-24 19:33:02 +03: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
f73f6125eb
commit
eb949aafae
@ -62,6 +62,10 @@ var path = require('path'),
|
||||
files: ['core/clientold/tpl/**/*.hbs'],
|
||||
tasks: ['handlebars']
|
||||
},
|
||||
shared: {
|
||||
files: ['core/shared/**/*.js'],
|
||||
tasks: ['concat:dev', 'concat:dev-ember']
|
||||
},
|
||||
'handlebars-ember': {
|
||||
files: ['core/client/**/*.hbs'],
|
||||
tasks: ['emberTemplates:dev']
|
||||
|
@ -1,34 +1,99 @@
|
||||
/* 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());
|
||||
};
|
||||
|
||||
var onScrollHandler = function (cm) {
|
||||
var scrollInfo = cm.getScrollInfo(),
|
||||
percentage = scrollInfo.top / scrollInfo.height,
|
||||
component = cm.component;
|
||||
|
||||
scrollInfo.codemirror = cm;
|
||||
|
||||
// throttle scroll updates
|
||||
component.throttle = Ember.run.throttle(component, function () {
|
||||
this.set('scrollPosition', percentage);
|
||||
}, 50);
|
||||
this.set('scrollInfo', scrollInfo);
|
||||
}, 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 () {
|
||||
// create codemirror
|
||||
this.codemirror = CodeMirror.fromTextArea(this.get('element'), {
|
||||
lineWrapping: true
|
||||
var codemirror = CodeMirror.fromTextArea(this.get('element'), {
|
||||
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
|
||||
this.codemirror.on('change', onChangeHandler);
|
||||
codemirror.on('change', onChangeHandler);
|
||||
|
||||
// on scroll update scrollPosition property
|
||||
this.codemirror.on('scroll', onScrollHandler);
|
||||
}.on('didInsertElement'),
|
||||
codemirror.on('scroll', onScrollHandler);
|
||||
|
||||
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 () {
|
||||
Ember.run.cancel(this.throttle);
|
||||
@ -36,8 +101,13 @@ var Codemirror = Ember.TextArea.extend({
|
||||
|
||||
removeCodemirrorHandlers: function () {
|
||||
// not sure if this is needed.
|
||||
this.codemirror.off('change', onChangeHandler);
|
||||
this.codemirror.off('scroll', onScrollHandler);
|
||||
var codemirror = this.get('codemirror');
|
||||
codemirror.off('change', onChangeHandler);
|
||||
codemirror.off('scroll');
|
||||
}.on('willDestroyElement'),
|
||||
|
||||
clearMarkerManagerMarkers: function () {
|
||||
this.clearMarkers();
|
||||
}.on('willDestroyElement')
|
||||
});
|
||||
|
||||
|
@ -1,11 +1,36 @@
|
||||
var Markdown = Ember.Component.extend({
|
||||
adjustScrollPosition: function () {
|
||||
var scrollWrapper = this.$().closest('.entry-preview-content').get(0),
|
||||
// calculate absolute scroll position from percentage
|
||||
scrollPixel = scrollWrapper.scrollHeight * this.get('scrollPosition');
|
||||
import uploader from 'ghost/assets/lib/uploader';
|
||||
|
||||
scrollWrapper.scrollTop = scrollPixel; // adjust scroll position
|
||||
}.observes('scrollPosition')
|
||||
var Markdown = Ember.Component.extend({
|
||||
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 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;
|
||||
|
@ -1,6 +1,17 @@
|
||||
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: {
|
||||
/**
|
||||
* Redirect to editor after the first save
|
||||
@ -17,4 +28,4 @@ var EditorNewController = Ember.ObjectController.extend(EditorControllerMixin, {
|
||||
}
|
||||
});
|
||||
|
||||
export default EditorNewController;
|
||||
export default EditorNewController;
|
||||
|
59
core/client/controllers/modals/leave-editor.js
Normal file
59
core/client/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) {
|
||||
return count(markdown || '');
|
||||
if (/^\s*$/.test(markdown)) {
|
||||
return '0 words';
|
||||
}
|
||||
|
||||
var count = counter(markdown || '');
|
||||
return count + (count === 1 ? ' word' : ' words');
|
||||
});
|
||||
|
||||
export default countWords;
|
@ -1,5 +1,5 @@
|
||||
/* global Showdown, Handlebars */
|
||||
var showdown = new Showdown.converter();
|
||||
var showdown = new Showdown.converter({extensions: ['ghostimagepreview', 'ghostgfm']});
|
||||
|
||||
var formatMarkdown = Ember.Handlebars.makeBoundHelper(function (markdown) {
|
||||
return new Handlebars.SafeString(showdown.makeHtml(markdown || ''));
|
||||
|
@ -1,6 +1,19 @@
|
||||
/* 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.
|
||||
* Only with a user-set value (via setSaveType action)
|
||||
@ -13,6 +26,90 @@ var EditorControllerMixin = Ember.Mixin.create({
|
||||
return this.get('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`.
|
||||
// Ember Data won't recognize/update them automatically
|
||||
// when returned from the server with ids.
|
||||
@ -29,9 +126,15 @@ var EditorControllerMixin = Ember.Mixin.create({
|
||||
var status = this.get('willPublish') ? 'published' : 'draft',
|
||||
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);
|
||||
return this.get('model').save().then(function (model) {
|
||||
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>' +
|
||||
model.get('status') + '</strong>.');
|
||||
@ -47,6 +150,57 @@ var EditorControllerMixin = Ember.Mixin.create({
|
||||
} else {
|
||||
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
core/client/mixins/editor-base-view.js
Normal file
58
core/client/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
core/client/mixins/marker-manager.js
Normal file
219
core/client/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,
|
||||
post,
|
||||
postId;
|
||||
|
||||
postId = Number(params.post_id);
|
||||
|
||||
if (!Number.isInteger(postId) || !Number.isFinite(postId) || postId <= 0) {
|
||||
@ -33,8 +34,42 @@ var EditorEditRoute = AuthenticatedRoute.extend(styleBody, {
|
||||
return self.transitionTo('posts.index');
|
||||
});
|
||||
},
|
||||
|
||||
serialize: function (model) {
|
||||
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'],
|
||||
|
||||
model: function () {
|
||||
return this.store.createRecord('post', {
|
||||
title: ''
|
||||
});
|
||||
return this.store.createRecord('post');
|
||||
},
|
||||
|
||||
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}}
|
||||
</div>
|
||||
{{gh-format-markdown markdown}}
|
||||
|
@ -10,16 +10,17 @@
|
||||
<a class="markdown-help" href="" {{action "openModal" "markdown"}}><span class="hidden">What is Markdown?</span></a>
|
||||
</header>
|
||||
<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 class="entry-preview">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
|
9
core/client/templates/modals/leave-editor.hbs
Normal file
9
core/client/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',
|
||||
classNames: ['entry-container'],
|
||||
scrollPosition: 0 // percentage of scroll position
|
||||
classNames: ['entry-container']
|
||||
});
|
||||
|
||||
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',
|
||||
templateName: 'editor/edit',
|
||||
classNames: ['entry-container'],
|
||||
scrollPosition: 0 // percentage of scroll position
|
||||
classNames: ['entry-container']
|
||||
});
|
||||
|
||||
export default EditorNewView;
|
||||
|
Loading…
Reference in New Issue
Block a user