diff --git a/ghost/admin/app/components/gh-koenig-editor-lexical.hbs b/ghost/admin/app/components/gh-koenig-editor-lexical.hbs new file mode 100644 index 0000000000..f24074f866 --- /dev/null +++ b/ghost/admin/app/components/gh-koenig-editor-lexical.hbs @@ -0,0 +1,61 @@ +
+ {{!-- full height content pane --}} + {{!-- template-lint-disable no-down-event-binding no-invalid-interactive no-passed-in-event-handlers --}} +
+ + + + + + + {{!-- --}} +
+
\ No newline at end of file diff --git a/ghost/admin/app/components/gh-koenig-editor-lexical.js b/ghost/admin/app/components/gh-koenig-editor-lexical.js new file mode 100644 index 0000000000..fc1d8850f7 --- /dev/null +++ b/ghost/admin/app/components/gh-koenig-editor-lexical.js @@ -0,0 +1,167 @@ +import Component from '@glimmer/component'; +import ghostPaths from 'ghost-admin/utils/ghost-paths'; +import {action} from '@ember/object'; +import {inject as service} from '@ember/service'; +import {tracked} from '@glimmer/tracking'; + +export default class GhKoenigEditorReactComponent extends Component { + @service settings; + + containerElement = null; + titleElement = null; + koenigEditor = null; + mousedownY = 0; + uploadUrl = `${ghostPaths().apiRoot}/images/upload/`; + + @tracked titleIsHovered = false; + @tracked titleIsFocused = false; + + get title() { + return this.args.title === '(Untitled)' ? '' : this.args.title; + } + + get accentColor() { + const color = this.settings.get('accentColor'); + if (color && color[0] === '#') { + return color.slice(1); + } + return color; + } + + @action + registerElement(element) { + this.containerElement = element; + } + + @action + trackMousedown(event) { + // triggered when a mousedown is registered on .gh-koenig-editor-pane + this.mousedownY = event.clientY; + } + + // Title actions ----------------------------------------------------------- + + @action + registerTitleElement(element) { + this.titleElement = element; + + // this is needed because focus event handler won't be fired if input has focus when rendering + if (this.titleElement === document.activeElement) { + this.titleIsFocused = true; + } + } + + @action + updateTitle(event) { + this.args.onTitleChange?.(event.target.value); + } + + @action + focusTitle() { + this.titleElement.focus(); + } + + @action + onTitleKeydown(event) { + let value = event.target.value; + let selectionStart = event.target.selectionStart; + + // enter will always focus the editor + // down arrow will only focus the editor when the cursor is at the + // end of the input to preserve the default OS behaviour + if ( + event.key === 'Enter' || + event.key === 'Tab' || + ((event.key === 'ArrowDown' || event.key === 'ArrowRight') && (!value || selectionStart === value.length)) + ) { + event.preventDefault(); + + // on Enter we also want to create a blank para if necessary + if (event.key === 'Enter') { + this._addParaAtTop(); + } + + this.koenigEditor.focus(); + } + } + + // Body actions ------------------------------------------------------------ + + @action + onEditorCreated(koenig) { + this._setupEditor(koenig); + this.args.onEditorCreated?.(koenig); + } + + @action + focusEditor(event) { + if (event.target.classList.contains('gh-koenig-editor-pane')) { + let editorCanvas = this.koenigEditor.element; + let {bottom} = editorCanvas.getBoundingClientRect(); + + // if a mousedown and subsequent mouseup occurs below the editor + // canvas, focus the editor and put the cursor at the end of the + // document + if (this.mousedownY > bottom && event.clientY > bottom) { + let {post} = this.koenigEditor; + let range = post.toRange(); + let {tailSection} = range; + + event.preventDefault(); + this.koenigEditor.focus(); + + // we should always have a visible cursor when focusing + // at the bottom so create an empty paragraph if last + // section is a card + if (tailSection.isCardSection) { + this.koenigEditor.run((postEditor) => { + let newSection = postEditor.builder.createMarkupSection('p'); + postEditor.insertSectionAtEnd(newSection); + tailSection = newSection; + }); + } + + this.koenigEditor.selectRange(tailSection.tailPosition()); + + // ensure we're scrolled to the bottom + this.containerElement.scrollTop = this.containerElement.scrollHeight; + } + } + } + + _setupEditor(koenig) { + let component = this; + + this.koenigEditor = koenig; + + // focus the title when pressing SHIFT+TAB + this.koenigEditor.registerKeyCommand({ + str: 'SHIFT+TAB', + run() { + component.focusTitle(); + return true; + } + }); + } + + _addParaAtTop() { + if (!this.koenigEditor) { + return; + } + + let editor = this.koenigEditor; + let section = editor.post.toRange().head.section; + + // create a blank paragraph at the top of the editor unless it's already + // a blank paragraph + if (section.isListItem || !section.isBlank || section.text !== '') { + editor.run((postEditor) => { + let {builder} = postEditor; + let newPara = builder.createMarkupSection('p'); + let sections = section.isListItem ? section.parent.parent.sections : section.parent.sections; + + postEditor.insertSectionBefore(sections, newPara, section); + }); + } + } +} diff --git a/ghost/admin/app/components/koenig-lexical-editor.hbs b/ghost/admin/app/components/koenig-lexical-editor.hbs new file mode 100644 index 0000000000..ed901509b3 --- /dev/null +++ b/ghost/admin/app/components/koenig-lexical-editor.hbs @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/ghost/admin/app/components/koenig-lexical-editor.js b/ghost/admin/app/components/koenig-lexical-editor.js new file mode 100644 index 0000000000..c961d4c286 --- /dev/null +++ b/ghost/admin/app/components/koenig-lexical-editor.js @@ -0,0 +1,99 @@ +import Component from '@glimmer/component'; +import React, {Suspense} from 'react'; + +class ErrorHandler extends React.Component { + state = { + hasError: false + }; + + static getDerivedStateFromError() { + return {hasError: true}; + } + + render() { + if (this.state.hasError) { + return ( +

Loading has failed. Try refreshing the browser!

+ ); + } + + return this.props.children; + } +} + +const fetchKoenig = function () { + let status = 'pending'; + let response; + + const fetchPackage = async () => { + if (window.KoenigLexical) { + return window.KoenigLexical; + } + + // the manual specification of the protocol in the import template string is + // required to work around ember-auto-import complaining about an unknown dynamic import + // during the build step + const GhostAdmin = window.GhostAdmin || window.Ember.Namespace.NAMESPACES.find(ns => ns.name === 'ghost-admin'); + const url = new URL(GhostAdmin.__container__.lookup('service:config').get('editor.lexicalUrl')); + + if (url.protocol === 'http:') { + await import(`http://${url.host}${url.pathname}`); + } else { + await import(`https://${url.host}${url.pathname}`); + } + + return window.KoenigLexical; + }; + + const suspender = fetchPackage().then( + (res) => { + status = 'success'; + response = res; + }, + (err) => { + status = 'error'; + response = err; + } + ); + + const read = () => { + switch (status) { + case 'pending': + throw suspender; + case 'error': + throw response; + default: + return response; + } + }; + + return {read}; +}; + +const editorResource = fetchKoenig(); + +const KoenigComposer = (props) => { + const {KoenigComposer: _KoenigComposer} = editorResource.read(); + return <_KoenigComposer {...props} />; +}; + +const KoenigEditor = (props) => { + const {KoenigEditor: _KoenigEditor} = editorResource.read(); + return <_KoenigEditor {...props} />; +}; + +export default class KoenigLexicalEditor extends Component { + ReactComponent = () => { + return ( +
+ + Loading editor...

}> + + + +
+
+
+ ); + }; +} diff --git a/ghost/admin/app/controllers/lexical-editor.js b/ghost/admin/app/controllers/lexical-editor.js new file mode 100644 index 0000000000..64eaadc764 --- /dev/null +++ b/ghost/admin/app/controllers/lexical-editor.js @@ -0,0 +1,1097 @@ +import ConfirmEditorLeaveModal from '../components/modals/editor/confirm-leave'; +import Controller, {inject as controller} from '@ember/controller'; +import DeletePostModal from '../components/modals/delete-post'; +import PostModel from 'ghost-admin/models/post'; +import boundOneWay from 'ghost-admin/utils/bound-one-way'; +import classic from 'ember-classic-decorator'; +import config from 'ghost-admin/config/environment'; +import isNumber from 'ghost-admin/utils/isNumber'; +import moment from 'moment'; +import {action, computed} from '@ember/object'; +import {alias, mapBy} from '@ember/object/computed'; +import {capitalize} from '@ember/string'; +import {dropTask, enqueueTask, restartableTask, task, taskGroup, timeout} from 'ember-concurrency'; +import {htmlSafe} from '@ember/template'; +import {isBlank} from '@ember/utils'; +import {isArray as isEmberArray} from '@ember/array'; +import {isHostLimitError, isServerUnreachableError, isVersionMismatchError} from 'ghost-admin/services/ajax'; +import {isInvalidError} from 'ember-ajax/errors'; +import {inject as service} from '@ember/service'; + +const DEFAULT_TITLE = '(Untitled)'; + +// time in ms to save after last content edit +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}`); +}); + +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: 'Update failed', + draft: 'Unscheduling failed', + published: 'Publish failed' + } + + } + }, + + success: { + post: { + published: { + published: 'Updated', + draft: 'Saved', + scheduled: 'Scheduled', + sent: 'Sent' + }, + draft: { + published: 'Published', + draft: 'Saved', + scheduled: 'Scheduled', + sent: 'Sent' + }, + scheduled: { + scheduled: 'Updated', + draft: 'Unscheduled', + published: 'Published', + sent: 'Sent' + }, + sent: { + sent: 'Updated' + } + } + } +}; + +@classic +export default class LexicalEditorController extends Controller { + @controller application; + + @service config; + @service feature; + @service membersCountCache; + @service modals; + @service notifications; + @service router; + @service slugGenerator; + @service session; + @service settings; + @service ui; + + /* public properties -----------------------------------------------------*/ + + shouldFocusTitle = false; + showReAuthenticateModal = false; + showUpgradeModal = false; + showDeleteSnippetModal = false; + showSettingsMenu = false; + hostLimitError = null; + + // koenig related properties + wordcount = null; + + /* private properties ----------------------------------------------------*/ + + _leaveConfirmed = false; + _previousTagNames = null; // set by setPost and _postSaved, used in hasDirtyAttributes + + /* computed properties ---------------------------------------------------*/ + + @alias('model') + post; + + // store the desired post status locally without updating the model, + // the model will only be updated when a save occurs + @boundOneWay('post.isPublished') + willPublish; + + @boundOneWay('post.isScheduled') + willSchedule; + + // updateSlugTask and saveTask 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 + @(taskGroup().enqueue()) + saveTasks; + + @mapBy('post.tags', 'name') + _tagNames; + + @computed(...watchedProps) + get hasDirtyAttributes() { + return this._hasDirtyAttributes(); + } + + set hasDirtyAttributes(value) { + // eslint-disable-next-line no-setter-return + return value; + } + + @computed + get _snippets() { + return this.store.peekAll('snippet'); + } + + @computed('_snippets.@each.{name,isNew}') + get snippets() { + return this._snippets + .reject(snippet => snippet.get('isNew')) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + @computed('session.user.{isAdmin,isEditor}') + get canManageSnippets() { + let {user} = this.session; + if (user.get('isAdmin') || user.get('isEditor')) { + return true; + } + return false; + } + + @computed('_autosaveTask.isRunning', '_timedSaveTask.isRunning') + get _autosaveRunning() { + let autosave = this.get('_autosaveTask.isRunning'); + let timedsave = this.get('_timedSaveTask.isRunning'); + + return autosave || timedsave; + } + + @computed('post.isDraft') + get _canAutosave() { + return config.environment !== 'test' && this.get('post.isDraft'); + } + + @action + updateScratch(mobiledoc) { + this.set('post.scratch', mobiledoc); + + // save 3 seconds after last edit + this._autosaveTask.perform(); + // force save at 60 seconds + this._timedSaveTask.perform(); + } + + @action + 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 + @action + 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); + } + } + + @action + save(options) { + return this.saveTask.perform(options); + } + + // used to prevent unexpected background saves. Triggered when opening + // publish menu, starting a manual save, and when leaving the editor + @action + cancelAutosave() { + this._autosaveTask.cancelAll(); + this._timedSaveTask.cancelAll(); + } + + // called by the "are you sure?" modal + @action + leaveEditor() { + let transition = this.leaveEditorTransition; + + if (!transition) { + this.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(); + } + + @action + openDeletePostModal() { + if (!this.get('post.isNew')) { + this.modals.open(DeletePostModal, { + post: this.post + }); + } + } + + @action + toggleReAuthenticateModal() { + if (this.showReAuthenticateModal) { + // closing, re-attempt save if needed + if (this._reauthSave) { + this.saveTask.perform(this._reauthSaveOptions); + } + + this._reauthSave = false; + this._reauthSaveOptions = null; + } + this.toggleProperty('showReAuthenticateModal'); + } + + @action + openUpgradeModal() { + this.set('showUpgradeModal', true); + } + + @action + closeUpgradeModal() { + this.set('showUpgradeModal', false); + } + + @action + setKoenigEditor(koenig) { + this._koenig = koenig; + + // remove any empty cards when displaying a draft post + // - empty cards may be left in draft posts due to autosave occuring + // whilst an empty card is present then the user closing the browser + // or refreshing the page + // TODO: not yet implemented in react editor + // if (this.post.isDraft) { + // this._koenig.cleanup(); + // } + } + + @action + updateWordCount(counts) { + this.set('wordCount', counts); + } + + @action + setFeatureImage(url) { + this.post.set('featureImage', url); + + if (this.post.isDraft) { + this.autosaveTask.perform(); + } + } + + @action + clearFeatureImage() { + this.post.set('featureImage', null); + this.post.set('featureImageAlt', null); + this.post.set('featureImageCaption', null); + + if (this.post.isDraft) { + this.autosaveTask.perform(); + } + } + + @action + setFeatureImageAlt(text) { + this.post.set('featureImageAlt', text); + + if (this.post.isDraft) { + this.autosaveTask.perform(); + } + } + + @action + setFeatureImageCaption(html) { + this.post.set('featureImageCaption', html); + + if (this.post.isDraft) { + this.autosaveTask.perform(); + } + } + + @action + toggleSettingsMenu() { + this.set('showSettingsMenu', !this.showSettingsMenu); + } + + @action + closeSettingsMenu() { + this.set('showSettingsMenu', false); + } + + @action + saveSnippet(snippet) { + let snippetRecord = this.store.createRecord('snippet', snippet); + return snippetRecord.save().then(() => { + this.notifications.closeAlerts('snippet.save'); + this.notifications.showNotification( + `Snippet saved as "${snippet.name}"`, + {type: 'success'} + ); + return snippetRecord; + }).catch((error) => { + if (!snippetRecord.errors.isEmpty) { + this.notifications.showAlert( + `Snippet save failed: ${snippetRecord.errors.messages.join('. ')}`, + {type: 'error', key: 'snippet.save'} + ); + } + snippetRecord.rollbackAttributes(); + throw error; + }); + } + + @action + toggleUpdateSnippetModal(snippetRecord, updatedProperties = {}) { + if (snippetRecord) { + this.set('snippetToUpdate', {snippetRecord, updatedProperties}); + } else { + this.set('snippetToUpdate', null); + } + } + + @action + updateSnippet() { + if (!this.snippetToUpdate) { + return Promise.reject(); + } + + const {snippetRecord, updatedProperties: {mobiledoc}} = this.snippetToUpdate; + snippetRecord.set('mobiledoc', mobiledoc); + + return snippetRecord.save().then(() => { + this.set('snippetToUpdate', null); + this.notifications.closeAlerts('snippet.save'); + this.notifications.showNotification( + `Snippet "${snippetRecord.name}" updated`, + {type: 'success'} + ); + return snippetRecord; + }).catch((error) => { + if (!snippetRecord.errors.isEmpty) { + this.notifications.showAlert( + `Snippet save failed: ${snippetRecord.errors.messages.join('. ')}`, + {type: 'error', key: 'snippet.save'} + ); + } + snippetRecord.rollbackAttributes(); + throw error; + }); + } + + @action + toggleDeleteSnippetModal(snippet) { + this.set('snippetToDelete', snippet); + } + + @action + deleteSnippet(snippet) { + return snippet.destroyRecord(); + } + + /* Public tasks ----------------------------------------------------------*/ + + // separate task for autosave so that it doesn't override a manual save + @dropTask + *autosaveTask() { + if (!this.get('saveTask.isRunning')) { + return yield this.saveTask.perform({ + silent: true, + backgroundSave: true + }); + } + } + + // save tasks cancels autosave before running, although this cancels the + // _xSave tasks that will also cancel the autosave task + @task({group: 'saveTasks'}) + *saveTask(options = {}) { + let prevStatus = this.get('post.status'); + let isNew = this.get('post.isNew'); + let status; + + this.cancelAutosave(); + + if (options.backgroundSave && !this.hasDirtyAttributes) { + return; + } + + 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('post.pastScheduledTime')) { + status = (!this.willSchedule && !this.willPublish) ? 'draft' : 'published'; + } else { + if (this.willPublish && !this.get('post.isScheduled')) { + status = 'published'; + } else if (this.willSchedule && !this.get('post.isPublished')) { + status = 'scheduled'; + } else if (this.get('post.isSent')) { + status = 'sent'; + } else { + status = 'draft'; + } + } + } + + // set manually here instead of in beforeSaveTask because the + // new publishing flow sets the post status manually on publish + this.set('post.status', status); + + yield this.beforeSaveTask.perform(options); + + try { + let post = yield this._savePostTask.perform(options); + + post.set('statusScratch', null); + + if (!options.silent) { + this._showSaveNotification(prevStatus, post.get('status'), isNew ? true : false); + } + + // redirect to edit route if saving a new record + if (isNew && post.get('id')) { + if (!this.leaveEditorTransition) { + this.replaceRoute('lexical-editor.edit', post); + } + return true; + } + + return post; + } catch (error) { + if (this.showReAuthenticateModal) { + this._reauthSave = true; + this._reauthSaveOptions = options; + return; + } + + this.set('post.status', prevStatus); + + if (error === undefined && this.post.errors.length === 0) { + // "handled" error from _saveTask + return; + } + + // trigger upgrade modal if forbidden(403) error + if (isHostLimitError(error)) { + this.post.rollbackAttributes(); + this.set('hostLimitError', error.payload.errors[0]); + this.set('showUpgradeModal', true); + return; + } + + // re-throw if we have a general server error + if (error && !isInvalidError(error)) { + this.send('error', error); + return; + } + + if (!options.silent) { + let errorOrMessages = error || this.get('post.errors.messages'); + this._showErrorAlert(prevStatus, this.get('post.status'), errorOrMessages); + // simulate a validation error for upstream tasks + throw undefined; + } + + return this.post; + } + } + + @task + *beforeSaveTask(options = {}) { + // ensure we remove any blank cards when performing a full save + if (!options.backgroundSave) { + // TODO: not yet implemented in react editor + // if (this._koenig) { + // this._koenig.cleanup(); + // this.set('hasDirtyAttributes', true); + // } + } + + // TODO: There's no need for (at least) most of these scratch values. + // Refactor so we're setting model attributes directly + + // Set the properties that are indirected + + // Set mobiledoc equal to what's in the editor but create a copy so that + // nested objects/arrays don't keep references which can mean that both + // scratch and mobiledoc get updated simultaneously + this.set('post.mobiledoc', JSON.parse(JSON.stringify(this.post.scratch || null))); + + // Set a default title + if (!this.get('post.titleScratch').trim()) { + this.set('post.titleScratch', DEFAULT_TITLE); + } + + this.set('post.title', this.get('post.titleScratch')); + this.set('post.customExcerpt', this.get('post.customExcerptScratch')); + this.set('post.footerInjection', this.get('post.footerExcerptScratch')); + this.set('post.headerInjection', this.get('post.headerExcerptScratch')); + this.set('post.metaTitle', this.get('post.metaTitleScratch')); + this.set('post.metaDescription', this.get('post.metaDescriptionScratch')); + this.set('post.ogTitle', this.get('post.ogTitleScratch')); + this.set('post.ogDescription', this.get('post.ogDescriptionScratch')); + this.set('post.twitterTitle', this.get('post.twitterTitleScratch')); + this.set('post.twitterDescription', this.get('post.twitterDescriptionScratch')); + this.set('post.emailSubject', this.get('post.emailSubjectScratch')); + + if (!this.get('post.slug')) { + this.saveTitleTask.cancelAll(); + + yield this.generateSlugTask.perform(); + } + } + + /* + * triggered by a user manually changing slug + */ + @task({group: 'saveTasks'}) + *updateSlugTask(_newSlug) { + let slug = this.get('post.slug'); + let newSlug, serverSlug; + + newSlug = _newSlug || slug; + newSlug = newSlug && newSlug.trim(); + + // Ignore unchanged slugs or candidate slugs that are empty + if (!newSlug || slug === newSlug) { + // reset the input to its previous state + this.set('slugValue', slug); + return; + } + + serverSlug = yield this.slugGenerator.generateSlug('post', newSlug); + + // If after getting the sanitized and unique slug back from the API + // we end up with a slug that matches the existing slug, abort the change + if (serverSlug === slug) { + return; + } + + // Because the server transforms the candidate slug by stripping + // certain characters and appending a number onto the end of slugs + // to enforce uniqueness, there are cases where we can get back a + // candidate slug that is a duplicate of the original except for + // the trailing incrementor (e.g., this-is-a-slug and this-is-a-slug-2) + + // get the last token out of the slug candidate and see if it's a number + let slugTokens = serverSlug.split('-'); + let check = Number(slugTokens.pop()); + + // if the candidate slug is the same as the existing slug except + // for the incrementor then the existing slug should be used + if (isNumber(check) && check > 0) { + if (slug === slugTokens.join('-') && serverSlug !== newSlug) { + this.set('slugValue', slug); + + return; + } + } + + this.set('post.slug', serverSlug); + + // If this is a new post. Don't save the post. Defer the save + // to the user pressing the save button + if (this.get('post.isNew')) { + return; + } + + return yield this._savePostTask.perform(); + } + + // used in the PSM so that saves are sequential and don't trigger collision + // detection errors + @task({group: 'saveTasks'}) + *savePostTask() { + try { + return yield this._savePostTask.perform(); + } catch (error) { + if (error === undefined) { + // validation error + return; + } + + if (error) { + let status = this.get('post.status'); + this._showErrorAlert(status, status, error); + } + + throw error; + } + } + + // convenience method for saving the post and performing post-save cleanup + @task + *_savePostTask(options = {}) { + let {post} = this; + + const previousEmailOnlyValue = this.post.emailOnly; + + if (Object.prototype.hasOwnProperty.call(options, 'emailOnly')) { + this.post.set('emailOnly', options.emailOnly); + } + + try { + yield post.save(options); + } catch (error) { + this.post.set('emailOnly', previousEmailOnlyValue); + if (isServerUnreachableError(error)) { + const [prevStatus, newStatus] = this.post.changedAttributes().status || [this.post.status, this.post.status]; + this._showErrorAlert(prevStatus, newStatus, error); + + // simulate a validation error so we don't end up on a 500 screen + throw undefined; + } + + throw error; + } + + this.afterSave(post); + + return post; + } + + @action + afterSave(post) { + this.notifications.closeAlerts('post.save'); + + // remove any unsaved tags + // NOTE: `updateTags` changes `hasDirtyAttributes => true`. + // For a saved post it would otherwise be false. + post.updateTags(); + this._previousTagNames = this._tagNames; + + // update the scratch property if it's `null` and we get a blank mobiledoc + // back from the API - prevents "unsaved changes" modal on new+blank posts + if (!post.scratch) { + post.set('scratch', JSON.parse(JSON.stringify(post.get('mobiledoc')))); + } + + // 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); + } + } + + @task + *saveTitleTask() { + let post = this.post; + let currentTitle = post.get('title'); + let newTitle = post.get('titleScratch').trim(); + + if ((currentTitle && newTitle && newTitle === currentTitle) || (!currentTitle && !newTitle)) { + return; + } + + // this is necessary to force a save when the title is blank + this.set('hasDirtyAttributes', true); + + // generate a slug if a post is new and doesn't have a title yet or + // if the title is still '(Untitled)' + if ((post.get('isNew') && !currentTitle) || currentTitle === DEFAULT_TITLE) { + yield this.generateSlugTask.perform(); + } + + if (this.get('post.isDraft')) { + yield this.autosaveTask.perform(); + } + + this.ui.updateDocumentTitle(); + } + + @enqueueTask + *generateSlugTask() { + let title = this.get('post.titleScratch'); + + // Only set an "untitled" slug once per post + if (title === DEFAULT_TITLE && this.get('post.slug')) { + return; + } + + try { + let slug = yield this.slugGenerator.generateSlug('post', title); + + if (!isBlank(slug)) { + this.set('post.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.notifications.showAPIError(error); + } + } + } + + // load supplementel data such as the members count in the background + @restartableTask + *backgroundLoaderTask() { + yield this.store.query('snippet', {limit: 'all'}); + } + + /* Public methods --------------------------------------------------------*/ + + // called by the new/edit routes to change the post model + setPost(post) { + // don't do anything else if we're setting the same post + if (post === this.post) { + this.set('shouldFocusTitle', post.get('isNew')); + return; + } + + // reset everything ready for a new post + this.reset(); + + this.set('post', post); + this.backgroundLoaderTask.perform(); + + // autofocus the title if we have a new post + this.set('shouldFocusTitle', 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._tagNames; + + // 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.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' + + '=============================='; + } + }; + } + + // called by editor route's willTransition hook, fires for editor.new->edit, + // editor.edit->edit, or editor->any. Will either finish autosave then retry + // transition or abort and show the "are you sure want to leave?" modal + async willTransition(transition) { + let post = this.post; + + // exit early and allow transition if we have no post, occurs if reset + // has already been called + if (!post) { + return; + } + + // clean up blank cards when leaving the editor if we have a draft post + // - blank cards could be left around due to autosave triggering whilst + // a blank card is present then the user attempting to leave + // - will mark the post as dirty so it gets saved when transitioning + // TODO: not yet implemented in react editor + // if (this._koenig && post.isDraft) { + // this._koenig.cleanup(); + // } + + let hasDirtyAttributes = this.hasDirtyAttributes; + let state = post.getProperties('isDeleted', 'isSaving', 'hasDirtyAttributes', 'isNew'); + + let fromNewToEdit = this.router.currentRouteName === 'lexical-editor.new' + && transition.targetName === 'lexical-editor.edit' + && transition.intent.contexts + && transition.intent.contexts[0] + && transition.intent.contexts[0].id === post.id; + + let deletedWithoutChanges = state.isDeleted + && (state.isSaving || !state.hasDirtyAttributes); + + // 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 (!this._leaveConfirmed && !fromNewToEdit && !deletedWithoutChanges && hasDirtyAttributes) { + transition.abort(); + + // if a save is running, wait for it to finish then transition + if (this.saveTasks.isRunning) { + await this.saveTasks.last; + return transition.retry(); + } + + // if an autosave is scheduled, cancel it, save then transition + if (this._autosaveRunning) { + this.cancelAutosave(); + this.autosaveTask.cancelAll(); + + await this.autosaveTask.perform(); + return transition.retry(); + } + + // we genuinely have unsaved data, show the modal + if (this.post) { + Object.assign(this._leaveModalReason, {status: this.post.status}); + } + console.log('showing leave editor modal', this._leaveModalReason); // eslint-disable-line + + const reallyLeave = await this.modals.open(ConfirmEditorLeaveModal); + + if (reallyLeave !== true) { + return; + } else { + this._leaveConfirmed = true; + transition.retry(); + } + } + + // 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 !== 'lexical-editor.edit') { + this.reset(); + } + } + + // called when the editor route is left or the post model is swapped + reset() { + let post = this.post; + + // make sure the save tasks aren't still running in the background + // after leaving the edit route + this.cancelAutosave(); + + 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')) { + post.deleteRecord(); + } else { + post.rollbackAttributes(); + } + } + + this._previousTagNames = []; + this._leaveConfirmed = false; + + this.set('post', null); + this.set('hasDirtyAttributes', false); + this.set('shouldFocusTitle', false); + this.set('showSettingsMenu', false); + this.set('wordCount', 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 + @(task(function* () { + if (!this._canAutosave) { + return; + } + + // force an instant save on first body edit for new posts + if (this.get('post.isNew')) { + return this.autosaveTask.perform(); + } + + yield timeout(AUTOSAVE_TIMEOUT); + this.autosaveTask.perform(); + }).restartable()) + _autosaveTask; + + // save at 60 seconds even if the user doesn't stop typing + @(task(function* () { + if (!this._canAutosave) { + return; + } + + while (config.environment !== 'test' && true) { + yield timeout(TIMEDSAVE_TIMEOUT); + this.autosaveTask.perform(); + } + }).drop()) + _timedSaveTask; + + /* Private methods -------------------------------------------------------*/ + + _hasDirtyAttributes() { + let post = this.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')) { + this._leaveModalReason = {reason: 'isError', context: post.errors.messages}; + return true; + } + + // post.tags is an array so hasDirtyAttributes doesn't pick up + // changes unless the array ref is changed + let currentTags = (this._tagNames || []).join(', '); + let previousTags = (this._previousTagNames || []).join(', '); + if (currentTags !== previousTags) { + this._leaveModalReason = {reason: 'tags are different', context: {currentTags, previousTags}}; + return true; + } + + // titleScratch isn't an attr so needs a manual dirty check + if (post.titleScratch !== post.title) { + this._leaveModalReason = {reason: 'title is different', context: {current: post.title, scratch: post.titleScratch}}; + return true; + } + + // scratch isn't an attr so needs a manual dirty check + let mobiledoc = post.get('mobiledoc'); + let scratch = post.get('scratch'); + // additional guard in case we are trying to compare null with undefined + if (scratch || mobiledoc) { + let mobiledocJSON = JSON.stringify(mobiledoc); + let scratchJSON = JSON.stringify(scratch); + + if (scratchJSON !== mobiledocJSON) { + this._leaveModalReason = {reason: 'mobiledoc is different', context: {current: mobiledocJSON, scratch: scratchJSON}}; + 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()); + + if (changedAttributes.length) { + this._leaveModalReason = {reason: 'post.changedAttributes.length > 0', context: 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 + let {hasDirtyAttributes} = post; + + if (hasDirtyAttributes) { + this._leaveModalReason = {reason: 'post.hasDirtyAttributes === true', context: post.changedAttributes()}; + } + + return hasDirtyAttributes; + } + + _showSaveNotification(prevStatus, status, delayed) { + // scheduled messaging is completely custom + if (status === 'scheduled') { + return this._showScheduledNotification(delayed); + } + + let notifications = this.notifications; + let message = messageMap.success.post[prevStatus][status]; + let actions, type, path; + + if (status === 'published' || status === 'scheduled') { + type = capitalize(this.get('post.displayName')); + path = this.get('post.url'); + actions = `View ${type}`; + } + + notifications.showNotification(message, {type: 'success', actions: (actions && htmlSafe(actions)), delayed}); + } + + async _showScheduledNotification(delayed) { + let { + publishedAtUTC, + previewUrl, + emailOnly, + newsletter + } = this.post; + let publishedAtBlogTZ = moment.tz(publishedAtUTC, this.settings.get('timezone')); + + let title = 'Scheduled'; + let description = emailOnly ? ['Will be sent'] : ['Will be published']; + + if (newsletter) { + const recipientCount = await this.membersCountCache.countString(this.post.fullRecipientFilter, {newsletter}); + description.push(`${!emailOnly ? 'and delivered ' : ''}to ${recipientCount}`); + } + + description.push(`on ${publishedAtBlogTZ.format('MMM Do')}`); + description.push(`at ${publishedAtBlogTZ.format('HH:mm')}`); + if (publishedAtBlogTZ.utcOffset() === 0) { + description.push('(UTC)'); + } else { + description.push(`(UTC${publishedAtBlogTZ.format('Z').replace(/([+-])0/, '$1').replace(/:00/, '')})`); + } + + description = htmlSafe(description.join(' ')); + + let actions = htmlSafe(`View Preview`); + + return this.notifications.showNotification(title, {description, actions, type: 'success', delayed}); + } + + _showErrorAlert(prevStatus, status, error, delay) { + let message = messageMap.errors.post[prevStatus][status]; + let notifications = this.notifications; + let errorMessage; + + function isString(str) { + return toString.call(str) === '[object String]'; + } + + if (isServerUnreachableError(error)) { + errorMessage = 'Unable to connect, please check your internet connection and try again'; + } else 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) { + return this.notifications.showAPIError(error, {key: 'post.save'}); + } else { + errorMessage = 'Unknown Error'; + } + + message += `: ${errorMessage}`; + message = htmlSafe(message); + + notifications.showAlert(message, {type: 'error', delayed: delay, key: 'post.save'}); + } +} diff --git a/ghost/admin/app/controllers/lexical-editor/edit-loading.js b/ghost/admin/app/controllers/lexical-editor/edit-loading.js new file mode 100644 index 0000000000..f811c909c2 --- /dev/null +++ b/ghost/admin/app/controllers/lexical-editor/edit-loading.js @@ -0,0 +1,5 @@ +import Controller from '@ember/controller'; +import {inject as service} from '@ember/service'; +export default class ReactEditLoadingController extends Controller { + @service ui; +} diff --git a/ghost/admin/app/router.js b/ghost/admin/app/router.js index 2035065e78..7d08c64ce0 100644 --- a/ghost/admin/app/router.js +++ b/ghost/admin/app/router.js @@ -41,6 +41,11 @@ Router.map(function () { this.route('edit', {path: ':type/:post_id'}); }); + this.route('lexical-editor', function () { + this.route('new', {path: ':type'}); + this.route('edit', {path: ':type/:post_id'}); + }); + this.route('tags'); this.route('tag.new', {path: '/tags/new'}); this.route('tag', {path: '/tags/:tag_slug'}); diff --git a/ghost/admin/app/routes/lexical-editor.js b/ghost/admin/app/routes/lexical-editor.js new file mode 100644 index 0000000000..4afd1b76b0 --- /dev/null +++ b/ghost/admin/app/routes/lexical-editor.js @@ -0,0 +1,74 @@ +import $ from 'jquery'; +import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; +import {run} from '@ember/runloop'; +import {inject as service} from '@ember/service'; + +export default AuthenticatedRoute.extend({ + config: service(), + feature: service(), + notifications: service(), + router: service(), + ui: service(), + + classNames: ['editor'], + + beforeModel() { + if (!this.config.get('editor.lexicalUrl')) { + return this.router.transitionTo('posts'); + } + }, + + activate() { + this._super(...arguments); + this.ui.set('isFullScreen', true); + }, + + deactivate() { + this._super(...arguments); + this.ui.set('isFullScreen', false); + }, + + actions: { + save() { + this._blurAndScheduleAction(function () { + this.controller.send('save'); + }); + }, + + authorizationFailed() { + this.controller.send('toggleReAuthenticateModal'); + }, + + 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.controller.willTransition(transition); + } + }, + + buildRouteInfoMetadata() { + return { + titleToken: () => { + return this.get('controller.post.title') || 'Editor'; + }, + bodyClasses: ['gh-body-fullscreen'], + mainClasses: ['gh-main-white'] + }; + }, + + _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/ghost/admin/app/routes/lexical-editor/edit.js b/ghost/admin/app/routes/lexical-editor/edit.js new file mode 100644 index 0000000000..e4b73c2318 --- /dev/null +++ b/ghost/admin/app/routes/lexical-editor/edit.js @@ -0,0 +1,67 @@ +import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; +import {pluralize} from 'ember-inflector'; + +export default class EditRoute extends AuthenticatedRoute { + beforeModel(transition) { + super.beforeModel(...arguments); + + // if the transition is not new->edit, reset the post on the controller + // so that the editor view is cleared before showing the loading state + if (transition.urlMethod !== 'replace') { + let editor = this.controllerFor('lexical-editor'); + editor.set('post', null); + editor.reset(); + } + } + + model(params, transition) { + // eslint-disable-next-line camelcase + let {type: modelName, post_id} = params; + + if (!['post', 'page'].includes(modelName)) { + let path = transition.intent.url.replace(/^\//, ''); + return this.replaceWith('error404', {path, status: 404}); + } + + let query = { + // eslint-disable-next-line camelcase + id: post_id + }; + + return this.store.query(modelName, 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) { + super.afterModel(...arguments); + + const user = this.session.user; + const returnRoute = pluralize(post.constructor.modelName); + + if (user.isAuthorOrContributor && !post.isAuthoredByUser(user)) { + return this.replaceWith(returnRoute); + } + + // If the post is not a draft and user is contributor, redirect to index + if (user.isContributor && !post.isDraft) { + return this.replaceWith(returnRoute); + } + } + + serialize(model) { + return { + type: model.constructor.modelName, + post_id: model.id + }; + } + + // there's no specific controller for this route, instead all editor + // handling is done on the editor route/controller + setupController(controller, post) { + let editor = this.controllerFor('lexical-editor'); + editor.setPost(post); + } +} diff --git a/ghost/admin/app/routes/lexical-editor/index.js b/ghost/admin/app/routes/lexical-editor/index.js new file mode 100644 index 0000000000..60e8e51732 --- /dev/null +++ b/ghost/admin/app/routes/lexical-editor/index.js @@ -0,0 +1,8 @@ +import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; + +export default class IndexRoute extends AuthenticatedRoute { + beforeModel() { + super.beforeModel(...arguments); + this.replaceWith('lexical-editor.new', 'post'); + } +} diff --git a/ghost/admin/app/routes/lexical-editor/new.js b/ghost/admin/app/routes/lexical-editor/new.js new file mode 100644 index 0000000000..1097c518b2 --- /dev/null +++ b/ghost/admin/app/routes/lexical-editor/new.js @@ -0,0 +1,27 @@ +import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; + +export default class NewRoute extends AuthenticatedRoute { + model(params, transition) { + let {type: modelName} = params; + + if (!['post','page'].includes(modelName)) { + let path = transition.intent.url.replace(/^\//, ''); + return this.replaceWith('error404', {path, status: 404}); + } + + return this.store.createRecord(modelName, {authors: [this.session.user]}); + } + + // 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('lexical-editor'); + editor.setPost(newPost); + } + + buildRouteInfoMetadata() { + return { + mainClasses: ['editor-new'] + }; + } +} diff --git a/ghost/admin/app/templates/lexical-editor.hbs b/ghost/admin/app/templates/lexical-editor.hbs new file mode 100644 index 0000000000..baeb2ae7c6 --- /dev/null +++ b/ghost/admin/app/templates/lexical-editor.hbs @@ -0,0 +1,151 @@ +{{#if this.post}} +
+ +
+ +
+ {{#if this.ui.isFullScreen}} + + + {{svg-jar "arrow-left"}} + {{capitalize (pluralize this.post.displayName)}} + + + {{/if}} +
+ + + +
+
+ +
+ {{#unless this.post.isNew}} + + + {{#unless this.showSettingsMenu}} +
+ {{/unless}} + {{/unless}} +
+
+
+ + {{!-- + gh-koenig-editor acts as a wrapper around the title input and + koenig editor canvas to support Ghost-specific editor behaviour + --}} + + +
+
+ {{gh-pluralize this.wordCount.wordCount "word"}} +
+ {{svg-jar "help"}} +
+ +
+ + {{#if this.showSettingsMenu}} + + {{/if}} +
+ + + + {{#if this.showReAuthenticateModal}} + + {{/if}} + + {{#if this.showUpgradeModal}} + + {{/if}} + + {{#if this.snippetToUpdate}} + + {{/if}} + + {{#if this.snippetToDelete}} + + {{/if}} +{{/if}} + +{{outlet}} diff --git a/ghost/admin/app/templates/lexical-editor/edit-loading.hbs b/ghost/admin/app/templates/lexical-editor/edit-loading.hbs new file mode 100644 index 0000000000..8255f3b779 --- /dev/null +++ b/ghost/admin/app/templates/lexical-editor/edit-loading.hbs @@ -0,0 +1,5 @@ +
+
+ +
+
diff --git a/ghost/admin/tests/acceptance/editor/lexical-test.js b/ghost/admin/tests/acceptance/editor/lexical-test.js new file mode 100644 index 0000000000..f522f3d63c --- /dev/null +++ b/ghost/admin/tests/acceptance/editor/lexical-test.js @@ -0,0 +1,43 @@ +import loginAsRole from '../../helpers/login-as-role'; +import {currentURL} from '@ember/test-helpers'; +import {expect} from 'chai'; +import {setupApplicationTest} from 'ember-mocha'; +import {setupMirage} from 'ember-cli-mirage/test-support'; +import {visit} from '../../helpers/visit'; + +describe('Acceptance: Lexical editor', function () { + let hooks = setupApplicationTest(); + setupMirage(hooks); + + beforeEach(async function () { + this.server.loadFixtures(); + }); + + it('redirects to signin when not authenticated', async function () { + await visit('/lexical-editor/post/'); + expect(currentURL(), 'currentURL').to.equal('/signin'); + }); + + it('redirects to posts screen if editor.lexicalUrl config is missing', async function () { + await loginAsRole('Administrator', this.server); + await visit('/lexical-editor/post/'); + + expect(currentURL(), 'currentURL').to.equal('/posts'); + }); + + it('loads when editor.lexicalUrl is present', async function () { + const config = this.server.schema.configs.find(1); + config.attrs.editor = {lexicalUrl: 'https://cdn.pkg/editor.js'}; + config.save(); + + // stub loaded external module + window.KoenigLexical = { + KoenigComposer: () => null, + KoenigEditor: () => null + }; + + await loginAsRole('Administrator', this.server); + await visit('/lexical-editor/post/'); + expect(currentURL(), 'currentURL').to.equal('/lexical-editor/post/'); + }); +}); diff --git a/ghost/core/core/shared/config/defaults.json b/ghost/core/core/shared/config/defaults.json index 6dea9c79bf..899b88f248 100644 --- a/ghost/core/core/shared/config/defaults.json +++ b/ghost/core/core/shared/config/defaults.json @@ -154,7 +154,8 @@ "version": "0.9" }, "editor": { - "url": "https://unpkg.com/@tryghost/koenig-react/dist/umd/koenig-react.min.js" + "url": "https://unpkg.com/@tryghost/koenig-react/dist/umd/koenig-react.min.js", + "lexicalUrl": "https://unpkg.com/@tryghost/koenig-lexical-experiment/dist/koenig-lexical.umd.js" }, "tenor": { "googleApiKey": null,