/
editor-base-controller.js
393 lines (321 loc) · 13.4 KB
/
editor-base-controller.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
import Ember from 'ember';
import PostModel from 'ghost/models/post';
import boundOneWay from 'ghost/utils/bound-one-way';
import imageManager from 'ghost/utils/ed-image-manager';
const {Mixin, RSVP, computed, inject, observer, run} = Ember;
const {alias} = computed;
// 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,
editor: null,
submitting: false,
postSettingsMenuController: inject.controller('post-settings-menu'),
notifications: inject.service(),
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'),
// set by the editor route and `hasDirtyAttributes`. useful when checking
// whether the number of tags has changed for `hasDirtyAttributes`.
previousTagNames: null,
tagNames: computed('model.tags.@each.name', function () {
return this.get('model.tags').mapBy('name');
}),
postOrPage: computed('model.page', function () {
return this.get('model.page') ? 'Page' : 'Post';
}),
// 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') &&
model.get('scratch') === model.get('markdown')) {
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');
let markdown = model.get('markdown');
let title = model.get('title');
let titleScratch = model.get('titleScratch');
let scratch = this.get('editor').getValue();
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 markdown attribute
if (markdown !== 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.'
},
draft: {
published: 'Publish failed.',
draft: 'Saving failed.'
}
}
},
success: {
post: {
published: {
published: 'Updated.',
draft: 'Saved.'
},
draft: {
published: 'Published!',
draft: 'Saved.'
}
}
}
},
// TODO: Update for new notification click-action API
showSaveNotification(prevStatus, status, delay) {
let message = this.messageMap.success.post[prevStatus][status];
let path = this.get('model.absoluteUrl');
let type = this.get('postOrPage');
let notifications = this.get('notifications');
if (status === 'published') {
message += ` <a href="${path}">View ${type}</a>`;
}
notifications.showNotification(message.htmlSafe(), {delayed: delay});
},
showErrorAlert(prevStatus, status, errors, delay) {
let message = this.messageMap.errors.post[prevStatus][status];
let notifications = this.get('notifications');
let error;
function isString(str) {
/*global toString*/
return toString.call(str) === '[object String]';
}
if (errors && isString(errors)) {
error = errors;
} else if (errors && errors[0] && isString(errors[0])) {
error = errors[0];
} else if (errors && errors[0] && errors[0].message && isString(errors[0].message)) {
error = errors[0].message;
} else {
error = 'Unknown Error';
}
message += `<br />${error}`;
message = Ember.String.htmlSafe(message);
notifications.showAlert(message, {type: 'error', delayed: delay, key: 'post.save'});
},
actions: {
save(options) {
let status;
let prevStatus = this.get('model.status');
let isNew = this.get('model.isNew');
let autoSaveId = this._autoSaveId;
let timedSaveId = this._timedSaveId;
let psmController = this.get('postSettingsMenuController');
let promise;
options = options || {};
// when navigating quickly between pages autoSave will occasionally
// try to run after the editor has been torn down so bail out here
// before we throw errors
if (!this.get('editor').$()) {
return 0;
}
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 {
status = this.get('willPublish') ? 'published' : 'draft';
}
if (autoSaveId) {
run.cancel(autoSaveId);
this._autoSaveId = null;
}
if (timedSaveId) {
run.cancel(timedSaveId);
this._timedSaveId = null;
}
// Set the properties that are indirected
// set markdown equal to what's in the editor, minus the image markers.
this.set('model.markdown', this.get('editor').getValue());
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.meta_title', psmController.get('metaTitleScratch'));
this.set('model.meta_description', psmController.get('metaDescriptionScratch'));
if (!this.get('model.slug')) {
// Cancel any pending slug generation that may still be queued in the
// run loop because we need to run it before the post is saved.
run.cancel(psmController.get('debounceId'));
psmController.generateAndSetSlug('model.slug');
}
promise = RSVP.resolve(psmController.get('lastPromise')).then(() => {
return this.get('model').save(options).then((model) => {
if (!options.silent) {
this.showSaveNotification(prevStatus, model.get('status'), isNew ? true : false);
}
this.toggleProperty('submitting');
return model;
});
}).catch((errors) => {
if (!options.silent) {
errors = errors || this.get('model.errors.messages');
this.showErrorAlert(prevStatus, this.get('model.status'), errors);
}
this.set('model.status', prevStatus);
this.toggleProperty('submitting');
return this.get('model');
});
psmController.set('lastPromise', promise);
return promise;
},
setSaveType(newType) {
if (newType === 'publish') {
this.set('willPublish', true);
} else if (newType === 'draft') {
this.set('willPublish', false);
}
},
// set from a `sendAction` on the gh-ed-editor component,
// so that we get a reference for handling uploads.
setEditor(editor) {
this.set('editor', editor);
},
// fired from the gh-ed-preview component when an image upload starts
disableEditor() {
this.get('editor').disable();
},
// fired from the gh-ed-preview component when an image upload finishes
enableEditor() {
this.get('editor').enable();
},
// 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(e, resultSrc) {
let editor = this.get('editor');
let editorValue = editor.getValue();
let replacement = imageManager.getSrcRange(editorValue, e.target);
let cursorPosition;
if (replacement) {
cursorPosition = replacement.start + resultSrc.length + 1;
if (replacement.needsParens) {
resultSrc = `(${resultSrc})`;
}
editor.replaceSelection(resultSrc, replacement.start, replacement.end, cursorPosition);
}
},
autoSaveNew() {
if (this.get('model.isNew')) {
this.send('save', {silent: true, backgroundSave: true});
}
},
updateEditorScrollInfo(scrollInfo) {
this.set('editorScrollInfo', scrollInfo);
},
updateHeight(height) {
this.set('height', height);
}
}
});