mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-30 01:42:29 +03:00
17dfc726b9
refs TryGhost/Ghost#7429, requires TryGhost/Ghost#7437 Added Ghost-Editor (based on mobiled doc). ------------------- - Added mobiledoc editor - Fixed problems with workflow and auto saves - Integrated basic toolbar - Removed all editor related tests, everything bar the most basic acceptance tests will be in the ghost-editor repository. - Commented out tests which relied on Ember Helpers that are not compatable with mobile-doc, workarounds are inbound shortly. This is the first integration of ghost-editor. It's styled enough to work, however it is not anywhere approaching something that looks remotely like what the finished thing will be. Early ALPHA, development build. Tread cautiously.
550 lines
20 KiB
JavaScript
550 lines
20 KiB
JavaScript
import Ember from 'ember';
|
|
import Mixin from 'ember-metal/mixin';
|
|
import RSVP from 'rsvp';
|
|
import computed, {alias, mapBy} from 'ember-computed';
|
|
import injectService from 'ember-service/inject';
|
|
import injectController from 'ember-controller/inject';
|
|
import {htmlSafe} from 'ember-string';
|
|
import observer from 'ember-metal/observer';
|
|
import run from 'ember-runloop';
|
|
import {isEmberArray} from 'ember-array/utils';
|
|
import {isBlank} from 'ember-utils';
|
|
|
|
import {task, timeout} from 'ember-concurrency';
|
|
|
|
import PostModel from 'ghost-admin/models/post';
|
|
import boundOneWay from 'ghost-admin/utils/bound-one-way';
|
|
import {isVersionMismatchError} from 'ghost-admin/services/ajax';
|
|
|
|
const {resolve} = RSVP;
|
|
|
|
// this array will hold properties we need to watch
|
|
// to know if the model has been changed (`controller.hasDirtyAttributes`)
|
|
const watchedProps = ['model.scratch', 'model.titleScratch', 'model.hasDirtyAttributes', 'model.tags.[]'];
|
|
|
|
PostModel.eachAttribute(function (name) {
|
|
watchedProps.push(`model.${name}`);
|
|
});
|
|
|
|
export default Mixin.create({
|
|
_autoSaveId: null,
|
|
_timedSaveId: null,
|
|
submitting: false,
|
|
|
|
showLeaveEditorModal: false,
|
|
showReAuthenticateModal: false,
|
|
|
|
postSettingsMenuController: injectController('post-settings-menu'),
|
|
notifications: injectService(),
|
|
clock: injectService(),
|
|
slugGenerator: injectService(),
|
|
|
|
init() {
|
|
this._super(...arguments);
|
|
window.onbeforeunload = () => {
|
|
return this.get('hasDirtyAttributes') ? this.unloadDirtyMessage() : null;
|
|
};
|
|
},
|
|
|
|
shouldFocusTitle: alias('model.isNew'),
|
|
shouldFocusEditor: false,
|
|
|
|
autoSave: observer('model.scratch', function () {
|
|
// Don't save just because we swapped out models
|
|
if (this.get('model.isDraft') && !this.get('model.isNew')) {
|
|
let autoSaveId,
|
|
saveOptions,
|
|
timedSaveId;
|
|
|
|
saveOptions = {
|
|
silent: true,
|
|
backgroundSave: true
|
|
};
|
|
|
|
timedSaveId = run.throttle(this, 'send', 'save', saveOptions, 60000, false);
|
|
this._timedSaveId = timedSaveId;
|
|
|
|
autoSaveId = run.debounce(this, 'send', 'save', saveOptions, 3000);
|
|
this._autoSaveId = autoSaveId;
|
|
}
|
|
}),
|
|
|
|
/**
|
|
* 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('model.isPublished'),
|
|
willSchedule: boundOneWay('model.isScheduled'),
|
|
scheduledWillPublish: boundOneWay('model.isPublished'),
|
|
|
|
// set by the editor route and `hasDirtyAttributes`. useful when checking
|
|
// whether the number of tags has changed for `hasDirtyAttributes`.
|
|
previousTagNames: null,
|
|
|
|
tagNames: mapBy('model.tags', 'name'),
|
|
|
|
postOrPage: computed('model.page', function () {
|
|
return this.get('model.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('model.status', 'clock.second', 'model.publishedAtUTC', 'model.timeScheduled', function () {
|
|
let status = this.get('model.status');
|
|
let publishTime = this.get('model.publishedAtUTC');
|
|
|
|
this.get('clock.second');
|
|
|
|
if (this.get('model.timeScheduled') && status === 'scheduled' && publishTime.diff(moment.utc(new Date()), 'minutes', true) < 15) {
|
|
return moment(publishTime).fromNow();
|
|
} else {
|
|
return false;
|
|
}
|
|
}),
|
|
|
|
// statusFreeze has two tasks:
|
|
// 1. 2 minutes before the scheduled time it will return true to change the button layout in gh-editor-save-button. There will be no
|
|
// dropdown menu, the save button gets the status 'isDangerous' to turn red and will only have the option to unschedule the post
|
|
// 2. when the scheduled time is reached we use a helper 'scheduledWillPublish' to pretend we're already dealing with a published post.
|
|
// This will take effect on the save button menu, the workflows and existing conditionals.
|
|
statusFreeze: computed('model.status', 'clock.second', 'model.publishedAtUTC', 'model.timeScheduled', function () {
|
|
let status = this.get('model.status');
|
|
let publishTime = this.get('model.publishedAtUTC');
|
|
|
|
this.get('clock.second');
|
|
|
|
if (this.get('model.timeScheduled') && status === 'scheduled' && publishTime.diff(moment.utc(new Date()), 'minutes', true) < 2) {
|
|
return true;
|
|
} else if (!this.get('model.timeScheduled') && !this.get('scheduledWillPublish') && status === 'scheduled' && publishTime.diff(moment.utc(new Date()), 'hours', true) < 0) {
|
|
// set the helper to true, until the model refreshed
|
|
this.set('scheduledWillPublish', true);
|
|
this.showSaveNotification('scheduled', 'published', false);
|
|
return false;
|
|
} 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
|
|
modelSaved() {
|
|
let model = this.get('model');
|
|
|
|
// safer to updateTags on save in one place
|
|
// rather than in all other places save is called
|
|
model.updateTags();
|
|
|
|
// set previousTagNames to current tagNames for hasDirtyAttributes check
|
|
this.set('previousTagNames', this.get('tagNames'));
|
|
|
|
// `updateTags` triggers `hasDirtyAttributes => true`.
|
|
// for a saved model it would otherwise be false.
|
|
|
|
// if the two "scratch" properties (title and content) match the model, then
|
|
// it's ok to set hasDirtyAttributes to false
|
|
if (model.get('titleScratch') === model.get('title') &&
|
|
JSON.stringify(model.get('scratch')) === JSON.stringify(model.get('mobiledoc'))) {
|
|
this.set('hasDirtyAttributes', false);
|
|
}
|
|
},
|
|
|
|
// an ugly hack, but necessary to watch all the model's properties
|
|
// and more, without having to be explicit and do it manually
|
|
hasDirtyAttributes: computed.apply(Ember, watchedProps.concat({
|
|
get() {
|
|
let model = this.get('model');
|
|
|
|
if (!model) {
|
|
return false;
|
|
}
|
|
|
|
// let markdown = model.get('markdown');
|
|
let mobiledoc = model.get('mobiledoc');
|
|
let title = model.get('title');
|
|
let titleScratch = model.get('titleScratch');
|
|
let scratch = this.get('model.scratch');
|
|
let changedAttributes;
|
|
|
|
if (!this.tagNamesEqual()) {
|
|
return true;
|
|
}
|
|
|
|
if (titleScratch !== title) {
|
|
return true;
|
|
}
|
|
|
|
// since `scratch` is not model property, we need to check
|
|
// it explicitly against the model's mobiledoc attribute
|
|
// TODO either deep equals or compare the serialised version - RYAN
|
|
if (mobiledoc !== scratch) {
|
|
return true;
|
|
}
|
|
|
|
// if the Adapter failed to save the model isError will be true
|
|
// and we should consider the model still dirty.
|
|
if (model.get('isError')) {
|
|
return true;
|
|
}
|
|
|
|
// models created on the client always return `hasDirtyAttributes: true`,
|
|
// so we need to see which properties have actually changed.
|
|
if (model.get('isNew')) {
|
|
changedAttributes = Object.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 `hasDirtyAttributes` property,
|
|
// `hasDirtyAttributes` will tell us if the other props have changed,
|
|
// as long as the model is not new (model.isNew === false).
|
|
return model.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('model.absoluteUrl');
|
|
} else {
|
|
type = 'Preview';
|
|
path = this.get('model.previewUrl');
|
|
}
|
|
|
|
message += ` <a href="${path}" target="_blank">View ${type}</a>`;
|
|
|
|
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.errors && error.errors[0].message) {
|
|
errorMessage = error.errors[0].message;
|
|
} else {
|
|
errorMessage = 'Unknown Error';
|
|
}
|
|
|
|
message += `: ${errorMessage}`;
|
|
message = htmlSafe(message);
|
|
|
|
notifications.showAlert(message, {type: 'error', delayed: delay, key: 'post.save'});
|
|
},
|
|
|
|
updateTitle: task(function* (newTitle) {
|
|
this.set('model.titleScratch', newTitle);
|
|
|
|
// if model is not new and title is not '(Untitled)', or model is new and
|
|
// has a title, don't generate a slug
|
|
if ((!this.get('model.isNew') || this.get('model.title')) && newTitle !== '(Untitled)') {
|
|
return;
|
|
}
|
|
|
|
// debounce for 700 milliseconds
|
|
yield timeout(700);
|
|
|
|
yield this.get('generateSlug').perform();
|
|
}).restartable(),
|
|
|
|
generateSlug: task(function* () {
|
|
let title = this.get('model.titleScratch');
|
|
|
|
// Only set an "untitled" slug once per post
|
|
if (title === '(Untitled)' && this.get('model.slug')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let slug = yield this.get('slugGenerator').generateSlug('post', title);
|
|
|
|
if (!isBlank(slug)) {
|
|
this.set('model.slug', slug);
|
|
}
|
|
} catch (error) {
|
|
// Nothing to do (would be nice to log this somewhere though),
|
|
// but a rejected promise needs to be handled here so that a resolved
|
|
// promise is returned.
|
|
if (isVersionMismatchError(error)) {
|
|
this.get('notifications').showAPIError(error);
|
|
}
|
|
}
|
|
}).enqueue(),
|
|
|
|
actions: {
|
|
cancelTimers() {
|
|
let autoSaveId = this._autoSaveId;
|
|
let timedSaveId = this._timedSaveId;
|
|
|
|
if (autoSaveId) {
|
|
run.cancel(autoSaveId);
|
|
this._autoSaveId = null;
|
|
}
|
|
|
|
if (timedSaveId) {
|
|
run.cancel(timedSaveId);
|
|
this._timedSaveId = null;
|
|
}
|
|
},
|
|
|
|
save(options) {
|
|
let prevStatus = this.get('model.status');
|
|
let isNew = this.get('model.isNew');
|
|
let psmController = this.get('postSettingsMenuController');
|
|
let promise, status;
|
|
|
|
options = options || {};
|
|
this.toggleProperty('submitting');
|
|
if (options.backgroundSave) {
|
|
// do not allow a post's status to be set to published by a background save
|
|
status = 'draft';
|
|
} else {
|
|
if (this.get('scheduledWillPublish')) {
|
|
status = (!this.get('willSchedule') && !this.get('willPublish')) ? 'draft' : 'published';
|
|
} else {
|
|
if (this.get('willPublish') && !this.get('model.isScheduled') && !this.get('statusFreeze')) {
|
|
status = 'published';
|
|
} else if (this.get('willSchedule') && !this.get('model.isPublished') && !this.get('statusFreeze')) {
|
|
status = 'scheduled';
|
|
} else {
|
|
status = 'draft';
|
|
}
|
|
}
|
|
}
|
|
|
|
this.send('cancelTimers');
|
|
|
|
// Set the properties that are indirected
|
|
// set mobiledoc equal to what's in the editor, minus the image markers.
|
|
this.set('model.mobiledoc', this.get('model.scratch'));
|
|
this.set('model.status', status);
|
|
|
|
// Set a default title
|
|
if (!this.get('model.titleScratch').trim()) {
|
|
this.set('model.titleScratch', '(Untitled)');
|
|
}
|
|
|
|
this.set('model.title', this.get('model.titleScratch'));
|
|
this.set('model.metaTitle', psmController.get('metaTitleScratch'));
|
|
this.set('model.metaDescription', psmController.get('metaDescriptionScratch'));
|
|
|
|
if (!this.get('model.slug')) {
|
|
this.get('updateTitle').cancelAll();
|
|
|
|
promise = this.get('generateSlug').perform();
|
|
}
|
|
|
|
return resolve(promise).then(() => {
|
|
return this.get('model').save(options).then((model) => {
|
|
if (!options.silent) {
|
|
this.showSaveNotification(prevStatus, model.get('status'), isNew ? true : false);
|
|
}
|
|
|
|
this.toggleProperty('submitting');
|
|
|
|
// reset the helper CP back to false after saving and refetching the new model
|
|
// which is published by the scheduler process on the server now
|
|
if (this.get('scheduledWillPublish')) {
|
|
this.set('scheduledWillPublish', false);
|
|
}
|
|
return model;
|
|
});
|
|
}).catch((error) => {
|
|
// re-throw if we have a general server error
|
|
// TODO: use isValidationError(error) once we have
|
|
// ember-ajax/ember-data integration
|
|
if (error && error.errors && error.errors[0].errorType !== 'ValidationError') {
|
|
this.toggleProperty('submitting');
|
|
this.send('error', error);
|
|
return;
|
|
}
|
|
|
|
if (!options.silent) {
|
|
error = error || this.get('model.errors.messages');
|
|
this.showErrorAlert(prevStatus, this.get('model.status'), error);
|
|
}
|
|
|
|
this.set('model.status', prevStatus);
|
|
|
|
this.toggleProperty('submitting');
|
|
return this.get('model');
|
|
});
|
|
},
|
|
|
|
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);
|
|
}
|
|
},
|
|
|
|
autoSaveNew() {
|
|
if (this.get('model.isNew')) {
|
|
this.send('save', {silent: true, backgroundSave: true});
|
|
}
|
|
},
|
|
|
|
toggleLeaveEditorModal(transition) {
|
|
this.set('leaveEditorTransition', transition);
|
|
this.toggleProperty('showLeaveEditorModal');
|
|
},
|
|
|
|
leaveEditor() {
|
|
let transition = this.get('leaveEditorTransition');
|
|
let model = this.get('model');
|
|
|
|
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;
|
|
}
|
|
|
|
// definitely want to clear the data store and post of any unsaved, client-generated tags
|
|
model.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.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() {
|
|
let currentTitle = this.model.get('title');
|
|
let newTitle = this.model.get('titleScratch').trim();
|
|
|
|
if (currentTitle === newTitle) {
|
|
return;
|
|
}
|
|
|
|
if (this.get('model.isDraft') && !this.get('model.isNew')) {
|
|
// this is preferrable to setting hasDirtyAttributes to false manually
|
|
this.model.set('title', newTitle);
|
|
|
|
this.send('save', {
|
|
silent: true,
|
|
backgroundSave: true
|
|
});
|
|
}
|
|
},
|
|
|
|
toggleReAuthenticateModal() {
|
|
this.toggleProperty('showReAuthenticateModal');
|
|
}
|
|
}
|
|
});
|