mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-01-06 22:40:14 -05:00
Lexical-powered editor experiment (#15278)
no issue We're spending a bit of time playing with an alternative to mobiledoc-kit to test it's feasibility as a base for future editor improvements. - add `editor.lexicalUrl` config that points at the unpkg release by default - set up a route on `/ghost/#/lexical-editor/post/` for the test playground which renders `<KoenigLexicialEditor>` as the editor - adds `<KoenigLexicalEditor>` component that lazy loads the external react component
This commit is contained in:
parent
df4c838443
commit
3fd32ce3cf
15 changed files with 1812 additions and 1 deletions
61
ghost/admin/app/components/gh-koenig-editor-lexical.hbs
Normal file
61
ghost/admin/app/components/gh-koenig-editor-lexical.hbs
Normal file
|
@ -0,0 +1,61 @@
|
|||
<div class="gh-koenig-editor relative w-100 vh-100 overflow-x-hidden overflow-y-auto z-0" {{did-insert this.registerElement}} ...attributes>
|
||||
{{!-- full height content pane --}}
|
||||
{{!-- template-lint-disable no-down-event-binding no-invalid-interactive no-passed-in-event-handlers --}}
|
||||
<div
|
||||
class="gh-koenig-editor-pane flex flex-column mih-100"
|
||||
{{on "mousedown" this.trackMousedown}}
|
||||
{{on "mouseup" this.focusEditor}}
|
||||
>
|
||||
<GhEditorFeatureImage
|
||||
@image={{@featureImage}}
|
||||
@updateImage={{@setFeatureImage}}
|
||||
@clearImage={{@clearFeatureImage}}
|
||||
@alt={{@featureImageAlt}}
|
||||
@updateAlt={{@setFeatureImageAlt}}
|
||||
@caption={{@featureImageCaption}}
|
||||
@updateCaption={{@setFeatureImageCaption}}
|
||||
@forceButtonDisplay={{or (not @title) (eq @title "(Untitled)") this.titleIsHovered this.titleIsFocused}}
|
||||
/>
|
||||
|
||||
<GhTextarea
|
||||
@class="gh-editor-title"
|
||||
@placeholder={{@titlePlaceholder}}
|
||||
@shouldFocus={{or @titleAutofocus false}}
|
||||
@tabindex="1"
|
||||
@autoExpand=".gh-koenig-editor"
|
||||
@value={{readonly this.title}}
|
||||
@input={{this.updateTitle}}
|
||||
@focus-out={{optional @onTitleBlur}}
|
||||
@keyDown={{this.onTitleKeydown}}
|
||||
@didCreateTextarea={{this.registerTitleElement}}
|
||||
{{on "focus" (fn (mut this.titleIsFocused) true)}}
|
||||
{{on "blur" (fn (mut this.titleIsFocused) false)}}
|
||||
{{on "mouseover" (fn (mut this.titleIsHovered) true)}}
|
||||
{{on "mouseleave" (fn (mut this.titleIsHovered) false)}}
|
||||
data-test-editor-title-input={{true}}
|
||||
/>
|
||||
|
||||
<KoenigLexicalEditor />
|
||||
|
||||
{{!-- <KoenigEditor
|
||||
@mobiledoc={{@body}}
|
||||
@placeholder={{@bodyPlaceholder}}
|
||||
@spellcheck={{true}}
|
||||
@onChange={{@onBodyChange}}
|
||||
@didCreateEditor={{this.onEditorCreated}}
|
||||
@cursorDidExitAtTop={{this.focusTitle}}
|
||||
@headerOffset={{@headerOffset}}
|
||||
@dropTargetSelector=".gh-koenig-editor-pane"
|
||||
@scrollContainerSelector={{@scrollContainerSelector}}
|
||||
@scrollOffsetTopSelector={{@scrollOffsetTopSelector}}
|
||||
@scrollOffsetBottomSelector={{@scrollOffsetBottomSelector}}
|
||||
@wordCountDidChange={{@onWordCountChange}}
|
||||
@snippets={{@snippets}}
|
||||
@saveSnippet={{@saveSnippet}}
|
||||
@updateSnippet={{@updateSnippet}}
|
||||
@deleteSnippet={{@deleteSnippet}}
|
||||
@cardOptions={{@cardOptions}}
|
||||
@postType={{@postType}}
|
||||
/> --}}
|
||||
</div>
|
||||
</div>
|
167
ghost/admin/app/components/gh-koenig-editor-lexical.js
Normal file
167
ghost/admin/app/components/gh-koenig-editor-lexical.js
Normal file
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
1
ghost/admin/app/components/koenig-lexical-editor.hbs
Normal file
1
ghost/admin/app/components/koenig-lexical-editor.hbs
Normal file
|
@ -0,0 +1 @@
|
|||
<div {{react-render this.ReactComponent}}></div>
|
99
ghost/admin/app/components/koenig-lexical-editor.js
Normal file
99
ghost/admin/app/components/koenig-lexical-editor.js
Normal file
|
@ -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 (
|
||||
<p className="koenig-react-editor-error">Loading has failed. Try refreshing the browser!</p>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={['koenig-react-editor', this.args.className].filter(Boolean).join(' ')}>
|
||||
<ErrorHandler>
|
||||
<Suspense fallback={<p className="koenig-react-editor-loading">Loading editor...</p>}>
|
||||
<KoenigComposer>
|
||||
<KoenigEditor />
|
||||
</KoenigComposer>
|
||||
</Suspense>
|
||||
</ErrorHandler>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
1097
ghost/admin/app/controllers/lexical-editor.js
Normal file
1097
ghost/admin/app/controllers/lexical-editor.js
Normal file
File diff suppressed because it is too large
Load diff
|
@ -0,0 +1,5 @@
|
|||
import Controller from '@ember/controller';
|
||||
import {inject as service} from '@ember/service';
|
||||
export default class ReactEditLoadingController extends Controller {
|
||||
@service ui;
|
||||
}
|
|
@ -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'});
|
||||
|
|
74
ghost/admin/app/routes/lexical-editor.js
Normal file
74
ghost/admin/app/routes/lexical-editor.js
Normal file
|
@ -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);
|
||||
}
|
||||
});
|
67
ghost/admin/app/routes/lexical-editor/edit.js
Normal file
67
ghost/admin/app/routes/lexical-editor/edit.js
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
8
ghost/admin/app/routes/lexical-editor/index.js
Normal file
8
ghost/admin/app/routes/lexical-editor/index.js
Normal file
|
@ -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');
|
||||
}
|
||||
}
|
27
ghost/admin/app/routes/lexical-editor/new.js
Normal file
27
ghost/admin/app/routes/lexical-editor/new.js
Normal file
|
@ -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']
|
||||
};
|
||||
}
|
||||
}
|
151
ghost/admin/app/templates/lexical-editor.hbs
Normal file
151
ghost/admin/app/templates/lexical-editor.hbs
Normal file
|
@ -0,0 +1,151 @@
|
|||
{{#if this.post}}
|
||||
<div class="flex flex-row">
|
||||
<GhEditor
|
||||
@tagName="section"
|
||||
@class="gh-editor gh-view relative"
|
||||
as |editor|
|
||||
>
|
||||
<header class="gh-editor-header br2 pe-none">
|
||||
<Editor::PublishManagement
|
||||
@post={{this.post}}
|
||||
@hasUnsavedChanges={{this.hasDirtyAttributes}}
|
||||
@beforePublish={{perform this.beforeSaveTask}}
|
||||
@afterPublish={{this.afterSave}}
|
||||
@saveTask={{this.saveTask}}
|
||||
as |publishManagement|
|
||||
>
|
||||
<div class="flex items-center pe-auto h-100">
|
||||
{{#if this.ui.isFullScreen}}
|
||||
<LinkTo @route={{pluralize this.post.displayName }} class="gh-btn-editor gh-editor-back-button" data-test-link={{pluralize this.post.displayName}}>
|
||||
<span>
|
||||
{{svg-jar "arrow-left"}}
|
||||
{{capitalize (pluralize this.post.displayName)}}
|
||||
</span>
|
||||
</LinkTo>
|
||||
{{/if}}
|
||||
<div class="gh-editor-post-status">
|
||||
<span>
|
||||
<GhEditorPostStatus
|
||||
@post={{this.post}}
|
||||
@hasDirtyAttributes={{this.hasDirtyAttributes}}
|
||||
@isSaving={{or this.autosaveTask.isRunning this.saveTasks.isRunning}}
|
||||
@openUpdateFlow={{publishManagement.openUpdateFlow}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="flex items-center pe-auto h-100">
|
||||
{{#unless this.post.isNew}}
|
||||
<Editor::PublishButtons @publishManagement={{publishManagement}} />
|
||||
|
||||
{{#unless this.showSettingsMenu}}
|
||||
<div class="settings-menu-toggle-spacer"></div>
|
||||
{{/unless}}
|
||||
{{/unless}}
|
||||
</section>
|
||||
</Editor::PublishManagement>
|
||||
</header>
|
||||
|
||||
{{!--
|
||||
gh-koenig-editor acts as a wrapper around the title input and
|
||||
koenig editor canvas to support Ghost-specific editor behaviour
|
||||
--}}
|
||||
<GhKoenigEditorLexical
|
||||
@title={{readonly this.post.titleScratch}}
|
||||
@titleAutofocus={{this.shouldFocusTitle}}
|
||||
@titlePlaceholder={{concat (capitalize this.post.displayName) " title"}}
|
||||
@onTitleChange={{this.updateTitleScratch}}
|
||||
@onTitleBlur={{perform this.saveTitleTask}}
|
||||
@body={{readonly this.post.scratch}}
|
||||
@bodyPlaceholder={{concat "Begin writing your " this.post.displayName "..."}}
|
||||
@onBodyChange={{this.updateScratch}}
|
||||
@headerOffset={{editor.headerHeight}}
|
||||
@scrollContainerSelector=".gh-koenig-editor"
|
||||
@scrollOffsetBottomSelector=".gh-mobile-nav-bar"
|
||||
@onEditorCreated={{this.setKoenigEditor}}
|
||||
@onWordCountChange={{this.updateWordCount}}
|
||||
@snippets={{this.snippets}}
|
||||
@saveSnippet={{if this.canManageSnippets this.saveSnippet}}
|
||||
@updateSnippet={{if this.canManageSnippets this.toggleUpdateSnippetModal}}
|
||||
@deleteSnippet={{if this.canManageSnippets this.toggleDeleteSnippetModal}}
|
||||
@featureImage={{this.post.featureImage}}
|
||||
@featureImageAlt={{this.post.featureImageAlt}}
|
||||
@featureImageCaption={{this.post.featureImageCaption}}
|
||||
@setFeatureImage={{this.setFeatureImage}}
|
||||
@setFeatureImageAlt={{this.setFeatureImageAlt}}
|
||||
@setFeatureImageCaption={{this.setFeatureImageCaption}}
|
||||
@clearFeatureImage={{this.clearFeatureImage}}
|
||||
@cardOptions={{hash
|
||||
post=this.post
|
||||
}}
|
||||
@postType={{this.post.displayName}}
|
||||
/>
|
||||
|
||||
<div class="gh-editor-wordcount-container">
|
||||
<div class="gh-editor-wordcount">
|
||||
{{gh-pluralize this.wordCount.wordCount "word"}}
|
||||
</div>
|
||||
<a href="https://ghost.org/help/using-the-editor/" class="flex" target="_blank" rel="noopener noreferrer">{{svg-jar "help"}}</a>
|
||||
</div>
|
||||
|
||||
</GhEditor>
|
||||
|
||||
{{#if this.showSettingsMenu}}
|
||||
<GhPostSettingsMenu
|
||||
@post={{this.post}}
|
||||
@deletePost={{this.openDeletePostModal}}
|
||||
@updateSlugTask={{this.updateSlugTask}}
|
||||
@savePostTask={{this.savePostTask}}
|
||||
/>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<button type="button" class="settings-menu-toggle gh-btn gh-btn-editor gh-btn-icon icon-only gh-btn-action-icon" title="Settings" {{on "click" this.toggleSettingsMenu}} data-test-psm-trigger>
|
||||
{{#if this.showSettingsMenu}}
|
||||
<span class="settings-menu-open">{{svg-jar "sidemenu-open"}}</span>
|
||||
{{else}}
|
||||
<span>{{svg-jar "sidemenu"}}</span>
|
||||
{{/if}}
|
||||
</button>
|
||||
|
||||
{{#if this.showReAuthenticateModal}}
|
||||
<GhFullscreenModal @modal="re-authenticate"
|
||||
@close={{this.toggleReAuthenticateModal}}
|
||||
@modifier="action wide" />
|
||||
{{/if}}
|
||||
|
||||
{{#if this.showUpgradeModal}}
|
||||
<GhFullscreenModal
|
||||
@modal="upgrade-host-limit"
|
||||
@model={{hash
|
||||
message=this.hostLimitError.context
|
||||
details=this.hostLimitError.details
|
||||
}}
|
||||
@close={{this.closeUpgradeModal}}
|
||||
@modifier="action wide"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.snippetToUpdate}}
|
||||
<GhFullscreenModal
|
||||
@modal="update-snippet"
|
||||
@model={{this.snippetToUpdate}}
|
||||
@confirm={{this.updateSnippet}}
|
||||
@close={{this.toggleUpdateSnippetModal}}
|
||||
@modifier="action wide"
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.snippetToDelete}}
|
||||
<GhFullscreenModal
|
||||
@modal="delete-snippet"
|
||||
@model={{this.snippetToDelete}}
|
||||
@confirm={{this.deleteSnippet}}
|
||||
@close={{this.toggleDeleteSnippetModal}}
|
||||
@modifier="action wide"
|
||||
/>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{outlet}}
|
|
@ -0,0 +1,5 @@
|
|||
<div class="gh-view" {{did-insert (fn this.ui.setMainClass "gh-main-white")}}>
|
||||
<div class="gh-content">
|
||||
<GhLoadingSpinner />
|
||||
</div>
|
||||
</div>
|
43
ghost/admin/tests/acceptance/editor/lexical-test.js
Normal file
43
ghost/admin/tests/acceptance/editor/lexical-test.js
Normal file
|
@ -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/');
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
|
|
Loading…
Reference in a new issue