diff --git a/app/components/gh-markdown-editor.js b/app/components/gh-markdown-editor.js
index 7876a99d8..9cf8f4b82 100644
--- a/app/components/gh-markdown-editor.js
+++ b/app/components/gh-markdown-editor.js
@@ -204,6 +204,13 @@ export default Component.extend(ShortcutsMixin, {
let markdown = mobiledoc.cards[0][1].markdown;
this.set('markdown', markdown);
+ // focus the editor when the markdown value changes, this is necessary
+ // because both the autofocus and markdown values can change without a
+ // re-render, eg. navigating from edit->new
+ if (this._editor && markdown !== this._editor.value() && this.get('autofocus')) {
+ this.send('focusEditor');
+ }
+
// use internal values to avoid updating bound values
if (!isEmpty(this.get('isFullScreen'))) {
this.set('_isFullScreen', this.get('isFullScreen'));
diff --git a/app/components/gh-scheduled-post-countdown.js b/app/components/gh-scheduled-post-countdown.js
new file mode 100644
index 000000000..bd0950ce1
--- /dev/null
+++ b/app/components/gh-scheduled-post-countdown.js
@@ -0,0 +1,28 @@
+import Component from '@ember/component';
+import moment from 'moment';
+import {computed} from '@ember/object';
+import {inject as service} from '@ember/service';
+
+export default Component.extend({
+ clock: service(),
+
+ post: null,
+
+ // countdown timer to show the time left until publish time for a scheduled post
+ // starts 15 minutes before scheduled time
+ countdown: computed('post.{publishedAtUTC,isScheduled}', 'clock.second', function () {
+ let isScheduled = this.get('post.isScheduled');
+ let publishTime = this.get('post.publishedAtUTC') || moment.utc();
+ let timeUntilPublished = publishTime.diff(moment.utc(), 'minutes', true);
+ let isPublishedSoon = timeUntilPublished > 0 && timeUntilPublished < 15;
+
+ // force a recompute
+ this.get('clock.second');
+
+ if (isScheduled && isPublishedSoon) {
+ return moment(publishTime).fromNow();
+ } else {
+ return false;
+ }
+ })
+});
diff --git a/app/components/gh-simplemde.js b/app/components/gh-simplemde.js
index cb6f66421..3ecc7afb5 100644
--- a/app/components/gh-simplemde.js
+++ b/app/components/gh-simplemde.js
@@ -76,8 +76,12 @@ export default TextArea.extend({
this._editor = new SimpleMDE(editorOptions);
this._editor.value(this.get('value') || '');
- this._editor.codemirror.on('change', () => {
- this.onChange(this._editor.value());
+ this._editor.codemirror.on('change', (instance, changeObj) => {
+ // avoid a "modified x twice in a single render" error that occurs
+ // when the underlying value is completely swapped out
+ if (changeObj.origin !== 'setValue') {
+ this.onChange(this._editor.value());
+ }
});
this._editor.codemirror.on('focus', () => {
diff --git a/app/mixins/editor-base-controller.js b/app/controllers/editor.js
similarity index 57%
rename from app/mixins/editor-base-controller.js
rename to app/controllers/editor.js
index 23acf8bc2..d6fbac1e7 100644
--- a/app/mixins/editor-base-controller.js
+++ b/app/controllers/editor.js
@@ -1,11 +1,9 @@
+import Controller from '@ember/controller';
import Ember from 'ember';
-import Mixin from '@ember/object/mixin';
import PostModel from 'ghost-admin/models/post';
import boundOneWay from 'ghost-admin/utils/bound-one-way';
-import ghostPaths from 'ghost-admin/utils/ghost-paths';
import isNumber from 'ghost-admin/utils/isNumber';
-import moment from 'moment';
-import {alias} from '@ember/object/computed';
+import {alias, mapBy, reads} from '@ember/object/computed';
import {computed} from '@ember/object';
import {inject as controller} from '@ember/controller';
import {htmlSafe} from '@ember/string';
@@ -13,14 +11,9 @@ import {isBlank} from '@ember/utils';
import {isArray as isEmberArray} from '@ember/array';
import {isInvalidError} from 'ember-ajax/errors';
import {isVersionMismatchError} from 'ghost-admin/services/ajax';
-import {mapBy, reads} from '@ember/object/computed';
import {inject as service} from '@ember/service';
import {task, taskGroup, timeout} from 'ember-concurrency';
-// this array will hold properties we need to watch
-// to know if the post has been changed (`controller.hasDirtyAttributes`)
-const watchedProps = ['post.scratch', 'post.titleScratch', 'post.hasDirtyAttributes', 'post.tags.[]'];
-
const DEFAULT_TITLE = '(Untitled)';
// time in ms to save after last content edit
@@ -28,75 +21,229 @@ const AUTOSAVE_TIMEOUT = 3000;
// time in ms to force a save if the user is continuously typing
const TIMEDSAVE_TIMEOUT = 60000;
+// this array will hold properties we need to watch for this.hasDirtyAttributes
+let watchedProps = ['post.scratch', 'post.titleScratch', 'post.hasDirtyAttributes', 'post.tags.[]', 'post.isError'];
+
+// add all post model attrs to the watchedProps array, easier to do it this way
+// than remember to update every time we add a new attr
PostModel.eachAttribute(function (name) {
watchedProps.push(`post.${name}`);
});
-export default Mixin.create({
+// TODO: This has to be moved to the I18n localization file.
+// This structure is supposed to be close to the i18n-localization which will be used soon.
+const messageMap = {
+ errors: {
+ post: {
+ published: {
+ published: 'Update failed',
+ draft: 'Saving failed',
+ scheduled: 'Scheduling failed'
+ },
+ draft: {
+ published: 'Publish failed',
+ draft: 'Saving failed',
+ scheduled: 'Scheduling failed'
+ },
+ scheduled: {
+ scheduled: 'Updated failed',
+ draft: 'Unscheduling failed',
+ published: 'Publish failed'
+ }
- post: alias('model'),
+ }
+ },
- showLeaveEditorModal: false,
- showReAuthenticateModal: false,
- showDeletePostModal: false,
- shouldFocusEditor: true,
+ success: {
+ post: {
+ published: {
+ published: 'Updated.',
+ draft: 'Saved.',
+ scheduled: 'Scheduled.'
+ },
+ draft: {
+ published: 'Published!',
+ draft: 'Saved.',
+ scheduled: 'Scheduled.'
+ },
+ scheduled: {
+ scheduled: 'Updated.',
+ draft: 'Unscheduled.',
+ published: 'Published!'
+ }
+ }
+ }
+};
+export default Controller.extend({
application: controller(),
notifications: service(),
- clock: service(),
+ router: service(),
slugGenerator: service(),
ui: service(),
- wordcount: 0,
- cards: [], // for apps
- atoms: [], // for apps
- toolbar: [], // for apps
- apiRoot: ghostPaths().apiRoot,
- assetPath: ghostPaths().assetRoot,
- editor: null,
- editorMenuIsOpen: false,
+ /* public properties -----------------------------------------------------*/
+ leaveEditorTransition: null,
+ shouldFocusEditor: false,
+ showDeletePostModal: false,
+ showLeaveEditorModal: false,
+ showReAuthenticateModal: false,
+
+ /* private properties ----------------------------------------------------*/
+
+ // set by setPost and _postSaved, used in hasDirtyAttributes
+ _previousTagNames: null,
+
+ /* computed properties ---------------------------------------------------*/
+
+ post: alias('model'),
+
+ // used within {{gh-editor}} as a trigger for responsive css changes
navIsClosed: reads('application.autoNav'),
- init() {
- this._super(...arguments);
- window.onbeforeunload = () => {
- if (this.get('hasDirtyAttributes')) {
- return this.unloadDirtyMessage();
- }
- };
- },
+ // store the desired post status locally without updating the model,
+ // the model will only be updated when a save occurs
+ willPublish: boundOneWay('post.isPublished'),
+ willSchedule: boundOneWay('post.isScheduled'),
- _canAutosave: computed('post.isDraft', function () {
- return !Ember.testing && this.get('post.isDraft'); // eslint-disable-line
+ // updateSlug and save should always be enqueued so that we don't run into
+ // problems with concurrency, for example when Cmd-S is pressed whilst the
+ // cursor is in the slug field - that would previously trigger a simultaneous
+ // slug update and save resulting in ember data errors and inconsistent save
+ // results
+ saveTasks: taskGroup().enqueue(),
+
+ _tagNames: mapBy('post.tags', 'name'),
+
+ // computed.apply is a bit of an ugly hack, but necessary to watch all the
+ // post's attributes and more without having to be explicit and remember
+ // to update the watched props list every time we add a new post attr
+ hasDirtyAttributes: computed.apply(Ember, watchedProps.concat({
+ get() {
+ return this._hasDirtyAttributes();
+ },
+ set(key, value) {
+ return value;
+ }
+ })),
+
+ _autosaveRunning: computed('_autosave.isRunning', '_timedSave.isRunning', function () {
+ let autosave = this.get('_autosave.isRunning');
+ let timedsave = this.get('_timedSave.isRunning');
+
+ return autosave || timedsave;
}),
- // save 3 seconds after the last edit
- _autosave: task(function* () {
- if (!this.get('_canAutosave')) {
- return;
- }
+ _canAutosave: computed('post.isDraft', function () {
+ return !Ember.testing && this.get('post.isDraft');
+ }),
- // force an instant save on first body edit for new posts
- if (this.get('post.isNew')) {
- return this.get('autosave').perform();
- }
+ /* actions ---------------------------------------------------------------*/
- yield timeout(AUTOSAVE_TIMEOUT);
- this.get('autosave').perform();
- }).restartable(),
+ actions: {
+ updateScratch(mobiledoc) {
+ this.set('post.scratch', mobiledoc);
- // save at 60 seconds even if the user doesn't stop typing
- _timedSave: task(function* () {
- if (!this.get('_canAutosave')) {
- return;
- }
+ // save 3 seconds after last edit
+ this.get('_autosave').perform();
+ // force save at 60 seconds
+ this.get('_timedSave').perform();
+ },
- while (!Ember.testing && true) { // eslint-disable-line
- yield timeout(TIMEDSAVE_TIMEOUT);
- this.get('autosave').perform();
+ updateTitleScratch(title) {
+ this.set('post.titleScratch', title);
+ },
+
+ // updates local willPublish/Schedule values, does not get applied to
+ // the post's `status` value until a save is triggered
+ setSaveType(newType) {
+ if (newType === 'publish') {
+ this.set('willPublish', true);
+ this.set('willSchedule', false);
+ } else if (newType === 'draft') {
+ this.set('willPublish', false);
+ this.set('willSchedule', false);
+ } else if (newType === 'schedule') {
+ this.set('willSchedule', true);
+ this.set('willPublish', false);
+ }
+ },
+
+ save(options) {
+ return this.get('save').perform(options);
+ },
+
+ // used to prevent unexpected background saves. Triggered when opening
+ // publish menu, starting a manual save, and when leaving the editor
+ cancelAutosave() {
+ this.get('_autosave').cancelAll();
+ this.get('_timedSave').cancelAll();
+ },
+
+ toggleLeaveEditorModal(transition) {
+ let leaveTransition = this.get('leaveEditorTransition');
+
+ // "cancel" was clicked in the "are you sure?" modal so we just
+ // reset the saved transition and remove the modal
+ if (!transition && this.get('showLeaveEditorModal')) {
+ this.set('leaveEditorTransition', null);
+ this.set('showLeaveEditorModal', false);
+ return;
+ }
+
+ if (!leaveTransition || transition.targetName === leaveTransition.targetName) {
+ this.set('leaveEditorTransition', transition);
+
+ // if a save is running, wait for it to finish then transition
+ if (this.get('saveTasks.isRunning')) {
+ return this.get('saveTasks.last').then(() => {
+ transition.retry();
+ });
+ }
+
+ // if an autosave is scheduled, cancel it, save then transition
+ if (this.get('_autosaveRunning')) {
+ this.send('cancelAutosave');
+ this.get('autosave').cancelAll();
+
+ return this.get('autosave').perform().then(() => {
+ transition.retry();
+ });
+ }
+
+ // we genuinely have unsaved data, show the modal
+ this.set('showLeaveEditorModal', true);
+ }
+ },
+
+ // called by the "are you sure?" modal
+ leaveEditor() {
+ let transition = this.get('leaveEditorTransition');
+
+ if (!transition) {
+ this.get('notifications').showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'});
+ return;
+ }
+
+ // perform cleanup and reset manually, ensures the transition will succeed
+ this.reset();
+
+ return transition.retry();
+ },
+
+ toggleDeletePostModal() {
+ if (!this.get('post.isNew')) {
+ this.toggleProperty('showDeletePostModal');
+ }
+ },
+
+ toggleReAuthenticateModal() {
+ this.toggleProperty('showReAuthenticateModal');
}
- }).drop(),
+ },
+
+ /* Public tasks ----------------------------------------------------------*/
// separate task for autosave so that it doesn't override a manual save
autosave: task(function* () {
@@ -108,20 +255,6 @@ export default Mixin.create({
}
}).drop(),
- _autosaveRunning: computed('_autosave.isRunning', '_timedSave.isRunning', function () {
- let autosave = this.get('_autosave.isRunning');
- let timedsave = this.get('_timedSave.isRunning');
-
- return autosave || timedsave;
- }),
-
- // updateSlug and save should always be enqueued so that we don't run into
- // problems with concurrency, for example when Cmd-S is pressed whilst the
- // cursor is in the slug field - that would previously trigger a simultaneous
- // slug update and save resulting in ember data errors and inconsistent save
- // results
- saveTasks: taskGroup().enqueue(),
-
// save tasks cancels autosave before running, although this cancels the
// _xSave tasks that will also cancel the autosave task
save: task(function* (options = {}) {
@@ -142,9 +275,9 @@ export default Mixin.create({
if (this.get('post.pastScheduledTime')) {
status = (!this.get('willSchedule') && !this.get('willPublish')) ? 'draft' : 'published';
} else {
- if (this.get('willPublish') && !this.get('post.isScheduled') && !this.get('statusFreeze')) {
+ if (this.get('willPublish') && !this.get('post.isScheduled')) {
status = 'published';
- } else if (this.get('willSchedule') && !this.get('post.isPublished') && !this.get('statusFreeze')) {
+ } else if (this.get('willSchedule') && !this.get('post.isPublished')) {
status = 'scheduled';
} else {
status = 'draft';
@@ -153,7 +286,7 @@ export default Mixin.create({
}
// Set the properties that are indirected
- // set mobiledoc equal to what's in the editor, minus the image markers.
+ // set mobiledoc equal to what's in the editor
this.set('post.mobiledoc', this.get('post.scratch'));
this.set('post.status', status);
@@ -183,7 +316,7 @@ export default Mixin.create({
let post = yield this.get('post').save(options);
if (!options.silent) {
- this.showSaveNotification(prevStatus, post.get('status'), isNew ? true : false);
+ this._showSaveNotification(prevStatus, post.get('status'), isNew ? true : false);
}
this.get('post').set('statusScratch', null);
@@ -208,7 +341,7 @@ export default Mixin.create({
if (!options.silent) {
let errorOrMessages = error || this.get('post.errors.messages');
- this.showErrorAlert(prevStatus, this.get('post.status'), errorOrMessages);
+ this._showErrorAlert(prevStatus, this.get('post.status'), errorOrMessages);
// simulate a validation error for upstream tasks
throw undefined;
}
@@ -281,252 +414,13 @@ export default Mixin.create({
} catch (error) {
if (error) {
let status = this.get('post.status');
- this.showErrorAlert(status, status, error);
+ this._showErrorAlert(status, status, error);
}
throw error;
}
}).group('saveTasks'),
- /**
- * By default, a post will not change its publish state.
- * Only with a user-set value (via setSaveType action)
- * can the post's status change.
- */
- willPublish: boundOneWay('post.isPublished'),
- willSchedule: boundOneWay('post.isScheduled'),
-
- // set by the editor route and `hasDirtyAttributes`. useful when checking
- // whether the number of tags has changed for `hasDirtyAttributes`.
- previousTagNames: null,
-
- tagNames: mapBy('post.tags', 'name'),
-
- postOrPage: computed('post.page', function () {
- return this.get('post.page') ? 'Page' : 'Post';
- }),
-
- // countdown timer to show the time left until publish time for a scheduled post
- // starts 15 minutes before scheduled time
- scheduleCountdown: computed('post.{publishedAtUTC,isScheduled}', 'clock.second', function () {
- let isScheduled = this.get('post.isScheduled');
- let publishTime = this.get('post.publishedAtUTC') || moment.utc();
- let timeUntilPublished = publishTime.diff(moment.utc(), 'minutes', true);
- let isPublishedSoon = timeUntilPublished > 0 && timeUntilPublished < 15;
-
- // force a recompute
- this.get('clock.second');
-
- if (isScheduled && isPublishedSoon) {
- return moment(publishTime).fromNow();
- } else {
- return false;
- }
- }),
-
- // compares previousTagNames to tagNames
- tagNamesEqual() {
- let tagNames = this.get('tagNames') || [];
- let previousTagNames = this.get('previousTagNames') || [];
- let 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;
- },
-
- // a hook created in editor-base-route's setupController
- postSaved() {
- let post = this.get('post');
-
- // safer to updateTags on save in one place
- // rather than in all other places save is called
- post.updateTags();
-
- // set previousTagNames to current tagNames for hasDirtyAttributes check
- this.set('previousTagNames', this.get('tagNames'));
-
- // `updateTags` triggers `hasDirtyAttributes => true`.
- // for a saved post it would otherwise be false.
-
- // if the two "scratch" properties (title and content) match the post, then
- // it's ok to set hasDirtyAttributes to false
- if (post.get('titleScratch') === post.get('title')
- && JSON.stringify(post.get('scratch')) === JSON.stringify(post.get('mobiledoc'))) {
- this.set('hasDirtyAttributes', false);
- }
- },
-
- // an ugly hack, but necessary to watch all the post's properties
- // and more, without having to be explicit and do it manually
- hasDirtyAttributes: computed.apply(Ember, watchedProps.concat({
- get() {
- let post = this.get('post');
-
- if (!post) {
- return false;
- }
-
- let mobiledoc = JSON.stringify(post.get('mobiledoc'));
- let scratch = JSON.stringify(post.get('scratch'));
- let title = post.get('title');
- let titleScratch = post.get('titleScratch');
- let changedAttributes;
-
- if (!this.tagNamesEqual()) {
- return true;
- }
-
- if (titleScratch !== title) {
- return true;
- }
-
- // since `scratch` is not post property, we need to check
- // it explicitly against the post's mobiledoc attribute
- if (mobiledoc !== scratch) {
- return true;
- }
-
- // if the Adapter failed to save the post isError will be true
- // and we should consider the post still dirty.
- if (post.get('isError')) {
- return true;
- }
-
- // posts created on the client always return `hasDirtyAttributes: true`,
- // so we need to see which properties have actually changed.
- if (post.get('isNew')) {
- changedAttributes = Object.keys(post.changedAttributes());
-
- if (changedAttributes.length) {
- return true;
- }
-
- return false;
- }
-
- // even though we use the `scratch` prop to show edits,
- // which does *not* change the post's `hasDirtyAttributes` property,
- // `hasDirtyAttributes` will tell us if the other props have changed,
- // as long as the post is not new (post.isNew === false).
- return post.get('hasDirtyAttributes');
- },
- set(key, value) {
- return value;
- }
- })),
-
- // used on window.onbeforeunload
- unloadDirtyMessage() {
- 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'
- + '==============================';
- },
-
- // TODO: This has to be moved to the I18n localization file.
- // This structure is supposed to be close to the i18n-localization which will be used soon.
- messageMap: {
- errors: {
- post: {
- published: {
- published: 'Update failed',
- draft: 'Saving failed',
- scheduled: 'Scheduling failed'
- },
- draft: {
- published: 'Publish failed',
- draft: 'Saving failed',
- scheduled: 'Scheduling failed'
- },
- scheduled: {
- scheduled: 'Updated failed',
- draft: 'Unscheduling failed',
- published: 'Publish failed'
- }
-
- }
- },
-
- success: {
- post: {
- published: {
- published: 'Updated.',
- draft: 'Saved.',
- scheduled: 'Scheduled.'
- },
- draft: {
- published: 'Published!',
- draft: 'Saved.',
- scheduled: 'Scheduled.'
- },
- scheduled: {
- scheduled: 'Updated.',
- draft: 'Unscheduled.',
- published: 'Published!'
- }
- }
- }
- },
-
- // TODO: Update for new notification click-action API
- showSaveNotification(prevStatus, status, delay) {
- let message = this.messageMap.success.post[prevStatus][status];
- let notifications = this.get('notifications');
- let type, path;
-
- if (status === 'published') {
- type = this.get('postOrPage');
- path = this.get('post.absoluteUrl');
- } else {
- type = 'Preview';
- path = this.get('post.previewUrl');
- }
-
- message += ` View ${type}`;
-
- notifications.showNotification(message.htmlSafe(), {delayed: delay});
- },
-
- showErrorAlert(prevStatus, status, error, delay) {
- let message = this.messageMap.errors.post[prevStatus][status];
- let notifications = this.get('notifications');
- let errorMessage;
-
- function isString(str) {
- /* global toString */
- return toString.call(str) === '[object String]';
- }
-
- if (error && isString(error)) {
- errorMessage = error;
- } else if (error && isEmberArray(error)) {
- // This is here because validation errors are returned as an array
- // TODO: remove this once validations are fixed
- errorMessage = error[0];
- } else if (error && error.payload && error.payload.errors && error.payload.errors[0].message) {
- errorMessage = error.payload.errors[0].message;
- } else {
- errorMessage = 'Unknown Error';
- }
-
- message += `: ${errorMessage}`;
- message = htmlSafe(message);
-
- notifications.showAlert(message, {type: 'error', delayed: delay, key: 'post.save'});
- },
-
saveTitle: task(function* () {
let post = this.get('post');
let currentTitle = post.get('title');
@@ -574,117 +468,281 @@ export default Mixin.create({
}
}).enqueue(),
- actions: {
- updateScratch(value) {
- this.set('post.scratch', value);
+ /* Public methods --------------------------------------------------------*/
- // save 3 seconds after last edit
- this.get('_autosave').perform();
- // force save at 60 seconds
- this.get('_timedSave').perform();
- },
+ // called by the new/edit routes to change the post model
+ setPost(post) {
+ // don't do anything if the post is the same
+ if (post === this.get('post')) {
+ return;
+ }
- cancelAutosave() {
- this.get('_autosave').cancelAll();
- this.get('_timedSave').cancelAll();
- },
+ // reset everything ready for a new post
+ this.reset();
- save(options) {
- return this.get('save').perform(options);
- },
+ this.set('post', post);
- setSaveType(newType) {
- if (newType === 'publish') {
- this.set('willPublish', true);
- this.set('willSchedule', false);
- } else if (newType === 'draft') {
- this.set('willPublish', false);
- this.set('willSchedule', false);
- } else if (newType === 'schedule') {
- this.set('willSchedule', true);
- this.set('willPublish', false);
+ // only autofocus the editor if we have a new post
+ this.set('shouldFocusEditor', post.get('isNew'));
+
+ // need to set scratch values because they won't be present on first
+ // edit of the post
+ // TODO: can these be `boundOneWay` on the model as per the other attrs?
+ post.set('titleScratch', post.get('title'));
+ post.set('scratch', post.get('mobiledoc'));
+
+ this._previousTagNames = this.get('_tagNames');
+ this._attachModelHooks();
+
+ // triggered any time the admin tab is closed, we need to use a native
+ // dialog here instead of our custom modal
+ window.onbeforeunload = () => {
+ if (this.get('hasDirtyAttributes')) {
+ 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'
+ + '==============================';
}
- },
+ };
+ },
- toggleLeaveEditorModal(transition) {
- let leaveTransition = this.get('leaveEditorTransition');
+ // called by editor route's willTransition hook, fires for editor.new->edit,
+ // editor.edit->edit, or editor->any. Triggers `toggleLeaveEditorModal` action
+ // which will either finish autosave then retry transition or abort and show
+ // the "are you sure?" modal
+ willTransition(transition) {
+ let post = this.get('post');
- if (!transition && this.get('showLeaveEditorModal')) {
- this.set('leaveEditorTransition', null);
- this.set('showLeaveEditorModal', false);
- return;
- }
+ // exit early and allow transition if we have no post, occurs if reset
+ // has already been called as in the `leaveEditor` action
+ if (!post) {
+ return;
+ }
- if (!leaveTransition || transition.targetName === leaveTransition.targetName) {
- this.set('leaveEditorTransition', transition);
+ let hasDirtyAttributes = this.get('hasDirtyAttributes');
+ let state = post.getProperties('isDeleted', 'isSaving', 'hasDirtyAttributes', 'isNew');
- // if a save is running, wait for it to finish then transition
- if (this.get('saveTasks.isRunning')) {
- return this.get('saveTasks.last').then(() => {
- transition.retry();
- });
- }
+ let fromNewToEdit = this.get('router.currentRouteName') === 'editor.new'
+ && transition.targetName === 'editor.edit'
+ && transition.intent.contexts
+ && transition.intent.contexts[0]
+ && transition.intent.contexts[0].id === post.get('id');
- // if an autosave is scheduled, cancel it, save then transition
- if (this.get('_autosaveRunning')) {
- this.send('cancelAutosave');
- this.get('autosave').cancelAll();
+ let deletedWithoutChanges = state.isDeleted
+ && (state.isSaving || !state.hasDirtyAttributes);
- return this.get('autosave').perform().then(() => {
- transition.retry();
- });
- }
+ // controller is dirty and we aren't in a new->edit or delete->index
+ // transition so show our "are you sure you want to leave?" modal
+ if (!fromNewToEdit && !deletedWithoutChanges && hasDirtyAttributes) {
+ transition.abort();
+ this.send('toggleLeaveEditorModal', transition);
+ return;
+ }
- // we genuinely have unsaved data, show the modal
- this.set('showLeaveEditorModal', true);
- }
- },
+ // the transition is now certain to complete so cleanup and reset if
+ // we're exiting the editor. new->edit keeps everything around and
+ // edit->edit will call reset in the setPost method if necessary
+ if (!fromNewToEdit && transition.targetName !== 'editor.edit') {
+ this.reset();
+ }
+ },
- leaveEditor() {
- let transition = this.get('leaveEditorTransition');
- let post = this.get('post');
+ // called when the editor route is left or the post model is swapped
+ reset() {
+ let post = this.get('post');
- if (!transition) {
- this.get('notifications').showAlert('Sorry, there was an error in the application. Please let the Ghost team know what happened.', {type: 'error'});
- return;
- }
+ // make sure the save tasks aren't still running in the background
+ // after leaving the edit route
+ this.send('cancelAutosave');
- // definitely want to clear the data store and post of any unsaved, client-generated tags
+ if (post) {
+ // clear post of any unsaved, client-generated tags
post.updateTags();
+ // remove new+unsaved records from the store and rollback any unsaved changes
if (post.get('isNew')) {
- // the user doesn't want to save the new, unsaved post, so delete it.
post.deleteRecord();
} else {
- // roll back changes on post props
post.rollbackAttributes();
}
- // setting hasDirtyAttributes to false here allows willTransition on the editor route to succeed
- this.set('hasDirtyAttributes', false);
-
- // since the transition is now certain to complete, we can unset window.onbeforeunload here
- window.onbeforeunload = null;
-
- return transition.retry();
- },
-
- updateTitle(newTitle) {
- this.set('post.titleScratch', newTitle);
- },
-
- toggleDeletePostModal() {
- if (!this.get('post.isNew')) {
- this.toggleProperty('showDeletePostModal');
- }
- },
-
- toggleReAuthenticateModal() {
- this.toggleProperty('showReAuthenticateModal');
- },
-
- setWordcount(wordcount) {
- this.set('wordcount', wordcount);
+ // remove the create/update event handlers that were added to the post
+ this._detachModelHooks();
}
+
+ this._previousTagNames = [];
+
+ this.set('post', null);
+ this.set('hasDirtyAttributes', false);
+ this.set('shouldFocusEditor', false);
+ this.set('leaveEditorTransition', null);
+
+ // remove the onbeforeunload handler as it's only relevant whilst on
+ // the editor route
+ window.onbeforeunload = null;
+ },
+
+ /* Private tasks ---------------------------------------------------------*/
+
+ // save 3 seconds after the last edit
+ _autosave: task(function* () {
+ if (!this.get('_canAutosave')) {
+ return;
+ }
+
+ // force an instant save on first body edit for new posts
+ if (this.get('post.isNew')) {
+ return this.get('autosave').perform();
+ }
+
+ yield timeout(AUTOSAVE_TIMEOUT);
+ this.get('autosave').perform();
+ }).restartable(),
+
+ // save at 60 seconds even if the user doesn't stop typing
+ _timedSave: task(function* () {
+ if (!this.get('_canAutosave')) {
+ return;
+ }
+
+ while (!Ember.testing && true) {
+ yield timeout(TIMEDSAVE_TIMEOUT);
+ this.get('autosave').perform();
+ }
+ }).drop(),
+
+ /* Private methods -------------------------------------------------------*/
+
+ _hasDirtyAttributes() {
+ let post = this.get('post');
+
+ if (!post) {
+ return false;
+ }
+
+ // if the Adapter failed to save the post isError will be true
+ // and we should consider the post still dirty.
+ if (post.get('isError')) {
+ return true;
+ }
+
+ // post.tags is an array so hasDirtyAttributes doesn't pick up
+ // changes unless the array ref is changed
+ let currentTags = this.getWithDefault('_tagNames', []).join('');
+ let previousTags = this.getWithDefault('_previousTagNames', []).join('');
+ if (currentTags !== previousTags) {
+ return true;
+ }
+
+ // titleScratch isn't an attr so needs a manual dirty check
+ if (this.get('titleScratch') !== this.get('title')) {
+ return true;
+ }
+
+ // scratch isn't an attr so needs a manual dirty check
+ let mobiledoc = JSON.stringify(post.get('mobiledoc'));
+ let scratch = JSON.stringify(post.get('scratch'));
+ if (scratch !== mobiledoc) {
+ return true;
+ }
+
+ // new+unsaved posts always return `hasDirtyAttributes: true`
+ // so we need a manual check to see if any
+ if (post.get('isNew')) {
+ let changedAttributes = Object.keys(post.changedAttributes());
+ return changedAttributes.length ? true : false;
+ }
+
+ // we've covered all the non-tracked cases we care about so fall
+ // back on Ember Data's default dirty attribute checks
+ return post.get('hasDirtyAttributes');
+ },
+
+ // post.save() is called in multiple places, rather than remembering to
+ // add a .then in every instance we use model hooks to update our local
+ // values used for `hasDirtyAttributes`
+ _attachModelHooks() {
+ let post = this.get('post');
+ if (post) {
+ post.on('didCreate', this, this._postSaved);
+ post.on('didUpdate', this, this._postSaved);
+ }
+ },
+
+ _detachModelHooks() {
+ let post = this.get('post');
+ if (post) {
+ post.off('didCreate', this, this._postSaved);
+ post.off('didUpdate', this, this._postSaved);
+ }
+ },
+
+ _postSaved() {
+ let post = this.get('post');
+
+ // remove any unsaved tags
+ // NOTE: `updateTags` changes `hasDirtyAttributes => true`.
+ // For a saved post it would otherwise be false.
+ post.updateTags();
+
+ this._previousTagNames = this.get('_tagNames');
+
+ // if the two "scratch" properties (title and content) match the post,
+ // then it's ok to set hasDirtyAttributes to false
+ // TODO: why is this necessary?
+ let titlesMatch = post.get('titleScratch') === post.get('title');
+ let bodiesMatch = JSON.stringify(post.get('scratch')) === JSON.stringify(post.get('mobiledoc'));
+
+ if (titlesMatch && bodiesMatch) {
+ this.set('hasDirtyAttributes', false);
+ }
+ },
+
+ _showSaveNotification(prevStatus, status, delay) {
+ let message = messageMap.success.post[prevStatus][status];
+ let notifications = this.get('notifications');
+ let type, path;
+
+ if (status === 'published') {
+ type = this.get('post.page') ? 'Page' : 'Post';
+ path = this.get('post.absoluteUrl');
+ } else {
+ type = 'Preview';
+ path = this.get('post.previewUrl');
+ }
+
+ message += ` View ${type}`;
+
+ notifications.showNotification(message.htmlSafe(), {delayed: delay});
+ },
+
+ _showErrorAlert(prevStatus, status, error, delay) {
+ let message = messageMap.errors.post[prevStatus][status];
+ let notifications = this.get('notifications');
+ let errorMessage;
+
+ function isString(str) {
+ /* global toString */
+ return toString.call(str) === '[object String]';
+ }
+
+ if (error && isString(error)) {
+ errorMessage = error;
+ } else if (error && isEmberArray(error)) {
+ // This is here because validation errors are returned as an array
+ // TODO: remove this once validations are fixed
+ errorMessage = error[0];
+ } else if (error && error.payload && error.payload.errors && error.payload.errors[0].message) {
+ errorMessage = error.payload.errors[0].message;
+ } else {
+ errorMessage = 'Unknown Error';
+ }
+
+ message += `: ${errorMessage}`;
+ message = htmlSafe(message);
+
+ notifications.showAlert(message, {type: 'error', delayed: delay, key: 'post.save'});
}
+
});
diff --git a/app/controllers/editor/edit.js b/app/controllers/editor/edit.js
deleted file mode 100644
index 1fee4eb89..000000000
--- a/app/controllers/editor/edit.js
+++ /dev/null
@@ -1,5 +0,0 @@
-/* eslint-disable ghost/ember/alias-model-in-controller */
-import Controller from '@ember/controller';
-import EditorControllerMixin from 'ghost-admin/mixins/editor-base-controller';
-
-export default Controller.extend(EditorControllerMixin);
diff --git a/app/controllers/editor/new.js b/app/controllers/editor/new.js
deleted file mode 100644
index 1fee4eb89..000000000
--- a/app/controllers/editor/new.js
+++ /dev/null
@@ -1,5 +0,0 @@
-/* eslint-disable ghost/ember/alias-model-in-controller */
-import Controller from '@ember/controller';
-import EditorControllerMixin from 'ghost-admin/mixins/editor-base-controller';
-
-export default Controller.extend(EditorControllerMixin);
diff --git a/app/mixins/editor-base-route.js b/app/mixins/editor-base-route.js
deleted file mode 100644
index 703a57f6a..000000000
--- a/app/mixins/editor-base-route.js
+++ /dev/null
@@ -1,141 +0,0 @@
-import $ from 'jquery';
-import Mixin from '@ember/object/mixin';
-import ShortcutsRoute from 'ghost-admin/mixins/shortcuts-route';
-import ctrlOrCmd from 'ghost-admin/utils/ctrl-or-cmd';
-import styleBody from 'ghost-admin/mixins/style-body';
-import {run} from '@ember/runloop';
-
-let generalShortcuts = {};
-
-generalShortcuts[`${ctrlOrCmd}+shift+p`] = 'publish';
-
-export default Mixin.create(styleBody, ShortcutsRoute, {
- classNames: ['editor'],
-
- shortcuts: generalShortcuts,
-
- actions: {
- save() {
- let selectedElement = $(document.activeElement);
-
- if (selectedElement.is('input[type="text"]')) {
- selectedElement.trigger('focusout');
- }
-
- run.scheduleOnce('actions', this, function () {
- this.get('controller').send('save');
- });
- },
-
- publish() {
- let controller = this.get('controller');
-
- controller.send('setSaveType', 'publish');
- controller.send('save');
- },
-
- willTransition(transition) {
- let controller = this.get('controller');
- let scratch = controller.get('post.scratch');
- let controllerIsDirty = controller.get('hasDirtyAttributes');
- let post = controller.get('post');
- let state = post.getProperties('isDeleted', 'isSaving', 'hasDirtyAttributes', 'isNew');
-
- if (this.get('upgradeStatus.isRequired')) {
- return this._super(...arguments);
- }
-
- let fromNewToEdit = this.get('routeName') === 'editor.new'
- && transition.targetName === 'editor.edit'
- && transition.intent.contexts
- && transition.intent.contexts[0]
- && transition.intent.contexts[0].id === post.get('id');
-
- let deletedWithoutChanges = state.isDeleted
- && (state.isSaving || !state.hasDirtyAttributes);
-
- if (!fromNewToEdit && !deletedWithoutChanges && controllerIsDirty) {
- transition.abort();
- controller.send('toggleLeaveEditorModal', transition);
- return;
- }
-
- // The controller may hold post state that will be lost in the
- // new->edit transition, so we need to apply it now.
- if (fromNewToEdit && controllerIsDirty) {
- if (scratch !== post.get('mobiledoc')) {
- post.set('mobiledoc', scratch);
- }
- }
-
- // make sure the save tasks aren't still running in the background
- // after leaving the edit route
- // TODO: the edit screen should really be a component so that we get
- // automatic state cleanup and task cancellation
- controller.send('cancelAutosave');
-
- if (state.isNew) {
- post.deleteRecord();
- }
-
- // since the transition is now certain to complete..
- window.onbeforeunload = null;
-
- // remove post-related listeners created in editor-base-route
- this.detachModelHooks(controller, post);
- }
- },
-
- attachModelHooks(controller, post) {
- // this will allow us to track when the post is saved and update the controller
- // so that we can be sure controller.hasDirtyAttributes is correct, without having to update the
- // controller on each instance of `post.save()`.
- //
- // another reason we can't do this on `post.save().then()` is because the post-settings-menu
- // also saves the post, and passing messages is difficult because we have two
- // types of editor controllers, and the PSM also exists on the posts.post route.
- //
- // The reason we can't just keep this functionality in the editor controller is
- // because we need to remove these handlers on `willTransition` in the editor route.
- post.on('didCreate', controller, controller.get('postSaved'));
- post.on('didUpdate', controller, controller.get('postSaved'));
- },
-
- detachModelHooks(controller, post) {
- post.off('didCreate', controller, controller.get('postSaved'));
- post.off('didUpdate', controller, controller.get('postSaved'));
- },
-
- setupController(controller, post) {
- let tags = post.get('tags');
-
- post.set('scratch', post.get('mobiledoc'));
- post.set('titleScratch', post.get('title'));
-
- // reset the leave editor transition so new->edit will still work
- controller.set('leaveEditorTransition', null);
-
- this._super(...arguments);
-
- if (tags) {
- // used to check if anything has changed in the editor
- controller.set('previousTagNames', tags.mapBy('name'));
- } else {
- controller.set('previousTagNames', []);
- }
-
- // trigger an immediate autosave timeout if post has changed between
- // new->edit (typical as first save will only contain the first char)
- // so that leaving the route waits for save instead of showing the
- // "Are you sure you want to leave?" modal unexpectedly
- if (!post.get('isNew') && post.get('hasDirtyAttributes')) {
- controller.get('_autosave').perform();
- }
-
- // reset save-on-first-change (gh-koenig specific)
- // controller._hasChanged = false;
-
- // attach post-related listeners created in editor-base-route
- this.attachModelHooks(controller, post);
- }
-});
diff --git a/app/routes/editor.js b/app/routes/editor.js
new file mode 100644
index 000000000..1d6da2fdb
--- /dev/null
+++ b/app/routes/editor.js
@@ -0,0 +1,59 @@
+import $ from 'jquery';
+import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
+import ShortcutsRoute from 'ghost-admin/mixins/shortcuts-route';
+import ctrlOrCmd from 'ghost-admin/utils/ctrl-or-cmd';
+import {run} from '@ember/runloop';
+
+let generalShortcuts = {};
+generalShortcuts[`${ctrlOrCmd}+shift+p`] = 'publish';
+
+export default AuthenticatedRoute.extend(ShortcutsRoute, {
+ classNames: ['editor'],
+ shortcuts: generalShortcuts,
+ titleToken: 'Editor',
+
+ actions: {
+ save() {
+ this._blurAndScheduleAction(function () {
+ this.get('controller').send('save');
+ });
+ },
+
+ publish() {
+ this._blurAndScheduleAction(function () {
+ this.get('controller').send('setSaveType', 'publish');
+ this.get('controller').send('save');
+ });
+ },
+
+ authorizationFailed() {
+ this.get('controller').send('toggleReAuthenticateModal');
+ },
+
+ redirectToContentScreen() {
+ this.transitionTo('posts');
+ },
+
+ willTransition(transition) {
+ // exit early if an upgrade is required because our extended route
+ // class will abort the transition and show an error
+ if (this.get('upgradeStatus.isRequired')) {
+ return this._super(...arguments);
+ }
+
+ this.get('controller').willTransition(transition);
+ }
+ },
+
+ _blurAndScheduleAction(func) {
+ let selectedElement = $(document.activeElement);
+
+ // TODO: we should trigger a blur for textareas as well as text inputs
+ if (selectedElement.is('input[type="text"]')) {
+ selectedElement.trigger('focusout');
+ }
+
+ // wait for actions triggered by the focusout to finish before saving
+ run.scheduleOnce('actions', this, func);
+ }
+});
diff --git a/app/routes/editor/edit.js b/app/routes/editor/edit.js
index dc4b88b48..3d2848bb5 100644
--- a/app/routes/editor/edit.js
+++ b/app/routes/editor/edit.js
@@ -1,37 +1,21 @@
-/* eslint-disable camelcase */
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
-import base from 'ghost-admin/mixins/editor-base-route';
-
-export default AuthenticatedRoute.extend(base, {
- titleToken: 'Editor',
-
- beforeModel(transition) {
- this.set('_transitionedFromNew', transition.data.fromNew);
-
- this._super(...arguments);
- },
+export default AuthenticatedRoute.extend({
model(params) {
- /* eslint-disable camelcase */
let query = {
id: params.post_id,
status: 'all',
staticPages: 'all',
formats: 'mobiledoc,plaintext'
};
- /* eslint-enable camelcase */
- return this.store.query('post', query).then((records) => {
- let post = records.get('firstObject');
-
- if (post) {
- return post;
- }
-
- return this.replaceWith('posts.index');
- });
+ return this.store.query('post', query)
+ .then(records => records.get('firstObject'));
},
+ // the API will return a post even if the logged in user doesn't have
+ // permission to edit it (all posts are public) so we need to do our
+ // own permissions check and redirect if necessary
afterModel(post) {
this._super(...arguments);
@@ -42,18 +26,10 @@ export default AuthenticatedRoute.extend(base, {
});
},
- setupController(controller) {
- this._super(...arguments);
- controller.set('shouldFocusEditor', this.get('_transitionedFromNew'));
- },
-
- actions: {
- authorizationFailed() {
- this.get('controller').send('toggleReAuthenticateModal');
- },
-
- redirectToContentScreen() {
- this.transitionTo('posts');
- }
+ // there's no specific controller for this route, instead all editor
+ // handling is done on the editor route/controler
+ setupController(controller, post) {
+ let editor = this.controllerFor('editor');
+ editor.setPost(post);
}
});
diff --git a/app/routes/editor/index.js b/app/routes/editor/index.js
deleted file mode 100644
index cd29a2c8d..000000000
--- a/app/routes/editor/index.js
+++ /dev/null
@@ -1,8 +0,0 @@
-import Route from '@ember/routing/route';
-
-export default Route.extend({
- beforeModel() {
- this._super(...arguments);
- this.transitionTo('editor.new');
- }
-});
diff --git a/app/routes/editor/new.js b/app/routes/editor/new.js
index e4f13f6c3..520be90d8 100644
--- a/app/routes/editor/new.js
+++ b/app/routes/editor/new.js
@@ -1,31 +1,16 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
-import base from 'ghost-admin/mixins/editor-base-route';
-
-export default AuthenticatedRoute.extend(base, {
- titleToken: 'Editor',
+export default AuthenticatedRoute.extend({
model() {
return this.get('session.user').then(user => (
- this.store.createRecord('post', {
- author: user
- })
+ this.store.createRecord('post', {author: user})
));
},
- renderTemplate(controller, model) {
- this.render('editor/edit', {
- controller,
- model
- });
- },
-
- actions: {
- willTransition(transition) {
- // decorate the transition object so the editor.edit route
- // knows this was the previous active route
- transition.data.fromNew = true;
-
- this._super(...arguments);
- }
+ // there's no specific controller for this route, instead all editor
+ // handling is done on the editor route/controler
+ setupController(controller, newPost) {
+ let editor = this.controllerFor('editor');
+ editor.setPost(newPost);
}
});
diff --git a/app/templates/components/gh-scheduled-post-countdown.hbs b/app/templates/components/gh-scheduled-post-countdown.hbs
new file mode 100644
index 000000000..10e0edb07
--- /dev/null
+++ b/app/templates/components/gh-scheduled-post-countdown.hbs
@@ -0,0 +1,3 @@
+{{#if countdown}}
+ {{yield post countdown}}
+{{/if}}
diff --git a/app/templates/editor.hbs b/app/templates/editor.hbs
new file mode 100644
index 000000000..3e8bcd7a4
--- /dev/null
+++ b/app/templates/editor.hbs
@@ -0,0 +1,162 @@
+{{#if post}}
+ {{#gh-editor
+ tagName="section"
+ class="gh-editor gh-view"
+ navIsClosed=navIsClosed
+ as |editor|
+ }}
+