Ghost/ghost/admin/app/components/gh-koenig-editor-lexical.js
Kevin Ansfield 887f4d3ac2
🐛 Fixed editor unsaved changes modal showing too often (#20787)
ref [ENG-661](https://linear.app/tryghost/issue/ENG-661/) 
ref [ONC-253](https://linear.app/tryghost/issue/ONC-253/)
ref [PLG-174](https://linear.app/tryghost/issue/PLG-174/)

- restored the original but reverted fix for unsaved changes modal from https://github.com/TryGhost/Ghost/pull/20687
- updated code to remove some incorrect early-falsy-return logic in `editorController.hasDirtyAttributes` that prevented save of unsaved changes on the underlying model (e.g. excerpt)
- updated unit tests so they are testing real post model instances and therefore are testing what we expect them to test
- added acceptance tests to ensure autosave is working for title and excerpt fields

---------

Co-authored-by: Ronald Langeveld <hi@ronaldlangeveld.com>
2024-08-19 18:03:13 +00:00

270 lines
8.3 KiB
JavaScript

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 GhKoenigEditorLexical extends Component {
@service settings;
@service feature;
containerElement = null;
titleElement = null;
excerptElement = null;
mousedownY = 0;
uploadUrl = `${ghostPaths().apiRoot}/images/upload/`;
editorAPI = null;
secondaryEditorAPI = null;
skipFocusEditor = false;
@tracked titleIsHovered = false;
@tracked titleIsFocused = false;
get title() {
return this.args.title === '(Untitled)' ? '' : this.args.title;
}
get accentColor() {
const color = this.settings.accentColor;
if (color && color[0] === '#') {
return color.slice(1);
}
return color;
}
get excerpt() {
return this.args.excerpt || '';
}
@action
registerElement(element) {
this.containerElement = element;
}
@action
trackMousedown(event) {
// triggered when a mousedown is registered on .gh-koenig-editor-pane
this.mousedownY = event.clientY;
// mousedown can select a card which can deselect another card meaning the
// mouseup/click event can occur outside of the initially clicked card, in
// which case we don't want to then "re-focus" the editor and cause unexpected
// selection changes
let skipFocus = false;
for (const elem of (event.path || event.composedPath())) {
if (elem.matches?.('[data-lexical-decorator], [data-kg-slash-menu]')) {
skipFocus = true;
break;
}
}
if (skipFocus) {
this.skipFocusEditor = true;
}
}
@action
editorPaneDragover(event) {
event.preventDefault();
}
@action
editorPaneDrop(event) {
if (event.dataTransfer.files.length > 0) {
event.preventDefault();
this.editorAPI?.insertFiles(Array.from(event.dataTransfer.files));
}
}
// 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
cleanPastedTitle(event) {
const pastedText = (event.clipboardData || window.clipboardData).getData('text');
if (!pastedText) {
return;
}
event.preventDefault();
const cleanValue = pastedText.replace(/(\n|\r)+/g, ' ').trim();
document.execCommand('insertText', false, cleanValue);
}
@action
focusTitle() {
this.titleElement.focus();
}
@action
onTitleKeydown(event) {
if (this.feature.editorExcerpt) {
// move cursor to the excerpt on
// - Tab (handled by browser)
// - Arrow Down/Right when input is empty or caret at end of input
// - Enter
const {key} = event;
const {value, selectionStart} = event.target;
if (key === 'Enter') {
event.preventDefault();
this.excerptElement?.focus();
}
if ((key === 'ArrowDown' || key === 'ArrowRight') && !event.shiftKey) {
const couldLeaveTitle = !value || selectionStart === value.length;
if (couldLeaveTitle) {
event.preventDefault();
this.excerptElement?.focus();
}
}
} else {
// move cursor to the editor on
// - Tab
// - Arrow Down/Right when input is empty or caret at end of input
// - Enter, creating an empty paragraph when editor is not empty
const {editorAPI} = this;
if (!editorAPI || event.originalEvent.isComposing) {
return;
}
const {key} = event;
const {value, selectionStart} = event.target;
const couldLeaveTitle = !value || selectionStart === value.length;
const arrowLeavingTitle = ['ArrowDown', 'ArrowRight'].includes(key) && couldLeaveTitle;
if (key === 'Enter' || key === 'Tab' || arrowLeavingTitle) {
event.preventDefault();
if (key === 'Enter' && !editorAPI.editorIsEmpty()) {
editorAPI.insertParagraphAtTop({focus: true});
} else {
editorAPI.focusEditor({position: 'top'});
}
}
}
}
// Subtitle ("excerpt") Actions -------------------------------------------
@action
registerExcerptElement(element) {
this.excerptElement = element;
}
@action
focusExcerpt() {
this.excerptElement?.focus();
// timeout ensures this occurs after the keyboard events
setTimeout(() => {
this.excerptElement?.setSelectionRange(-1, -1);
}, 0);
}
@action
onExcerptInput(event) {
this.args.setExcerpt?.(event.target.value);
}
@action
onExcerptKeydown(event) {
// move cursor to the title on
// - Shift+Tab (handled by the browser)
// - Arrow Up/Left when input is empty or caret at start of input
// move cursor to the editor on
// - Tab
// - Arrow Down/Right when input is empty or caret at end of input
// - Enter, creating an empty paragraph when editor is not empty
const {key} = event;
const {value, selectionStart} = event.target;
if ((key === 'ArrowUp' || key === 'ArrowLeft') && !event.shiftKey) {
const couldLeaveTitle = !value || selectionStart === 0;
if (couldLeaveTitle) {
event.preventDefault();
this.focusTitle();
}
}
const {editorAPI} = this;
const couldLeaveTitle = !value || selectionStart === value.length;
const arrowLeavingTitle = (key === 'ArrowRight' || key === 'ArrowDown') && couldLeaveTitle;
if (key === 'Enter' || (key === 'Tab' && !event.shiftKey) || arrowLeavingTitle) {
event.preventDefault();
if (key === 'Enter' && !editorAPI.editorIsEmpty()) {
editorAPI.insertParagraphAtTop({focus: true});
} else {
editorAPI.focusEditor({position: 'top'});
}
}
}
// move cursor to the editor on
// Body actions ------------------------------------------------------------
@action
registerEditorAPI(API) {
this.editorAPI = API;
this.args.registerAPI(API);
}
@action
registerSecondaryEditorAPI(API) {
this.secondaryEditorAPI = API;
this.args.registerSecondaryAPI(API);
}
// focus the editor when the editor canvas is clicked below the editor content,
// otherwise the browser will defocus the editor and the cursor will disappear
@action
focusEditor(event) {
if (!this.skipFocusEditor && event.target.classList.contains('gh-koenig-editor-pane') && this.editorAPI) {
let editorCanvas = this.editorAPI.editorInstance.getRootElement();
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 (event.pageY > bottom && event.clientY > bottom) {
event.preventDefault();
// we should always have a visible cursor when focusing
// at the bottom so create an empty paragraph if last
// section is a card
if (this.editorAPI.lastNodeIsDecorator()) {
this.editorAPI.insertParagraphAtBottom();
}
// Focus the editor
this.editorAPI.focusEditor({position: 'bottom'});
}
}
this.skipFocusEditor = false;
}
}