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}}
+
+
+
+
+ {{!--
+ gh-koenig-editor acts as a wrapper around the title input and
+ koenig editor canvas to support Ghost-specific editor behaviour
+ --}}
+
+
+
+
+
+
+ {{#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,