mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-01 13:54:35 +03:00
c97e06751e
no issue - Sentry was showing `card` being undefined at times when attempting to select/edit a card after it's inserted in `replaceWithCardSection()` (called by card menus and text expansions) but it's not been reproducible in local environments - it's unclear if the problem occurs due to the card render not happening in the immediate render queue or if it's some other problem - added a retry if the card is not found after the first render plus logs to Sentry and console when the unexpected state occurs for better insight
1716 lines
63 KiB
JavaScript
1716 lines
63 KiB
JavaScript
/*
|
|
* Based on ember-mobiledoc-editor
|
|
* https://github.com/bustle/ember-mobiledoc-editor
|
|
*/
|
|
|
|
import Browser from 'mobiledoc-kit/utils/browser';
|
|
import Component from '@ember/component';
|
|
import Editor from 'mobiledoc-kit/editor/editor';
|
|
import EmberObject, {computed, get} from '@ember/object';
|
|
import Key from 'mobiledoc-kit/utils/key';
|
|
import MobiledocRange from 'mobiledoc-kit/utils/cursor/range';
|
|
import calculateReadingTime from '../utils/reading-time';
|
|
import defaultAtoms, {ATOM_COMPONENT_MAP} from '../options/atoms';
|
|
import defaultCards, {CARD_COMPONENT_MAP, CARD_ICON_MAP} from '../options/cards';
|
|
import formatMarkdown from 'ghost-admin/utils/format-markdown';
|
|
import registerKeyCommands from '../options/key-commands';
|
|
import registerTextExpansions from '../options/text-expansions';
|
|
import validator from 'validator';
|
|
import {A} from '@ember/array';
|
|
import {TrackedObject} from 'tracked-built-ins';
|
|
import {action} from '@ember/object';
|
|
import {assign} from '@ember/polyfills';
|
|
import {camelize, capitalize} from '@ember/string';
|
|
import {captureMessage} from '@sentry/browser';
|
|
import {createParserPlugins} from '@tryghost/kg-parser-plugins';
|
|
import {getContentFromPasteEvent} from 'mobiledoc-kit/utils/parse-utils';
|
|
import {getLinkMarkupFromRange} from '../utils/markup-utils';
|
|
import {getOwner} from '@ember/application';
|
|
import {getParent} from '../lib/dnd/utils';
|
|
import {utils as ghostHelperUtils} from '@tryghost/helpers';
|
|
import {guidFor} from '@ember/object/internals';
|
|
import {isBlank} from '@ember/utils';
|
|
import {run} from '@ember/runloop';
|
|
import {inject as service} from '@ember/service';
|
|
import {svgJar} from 'ghost-admin/helpers/svg-jar';
|
|
|
|
const {countWords} = ghostHelperUtils;
|
|
const UNDO_DEPTH = 100;
|
|
|
|
export const ADD_CARD_HOOK = 'addComponent';
|
|
export const REMOVE_CARD_HOOK = 'removeComponent';
|
|
export const ADD_ATOM_HOOK = 'addAtomComponent';
|
|
export const REMOVE_ATOM_HOOK = 'removeAtomComponent';
|
|
|
|
// used in test helpers to grab a reference to the underlying mobiledoc editor
|
|
export const TESTING_EXPANDO_PROPERTY = '__mobiledoc_kit_editor';
|
|
|
|
// blank doc contains a single empty paragraph so that there's some content for
|
|
// the cursor to start in
|
|
export const MOBILEDOC_VERSION = '0.3.1';
|
|
export const BLANK_DOC = {
|
|
version: MOBILEDOC_VERSION,
|
|
ghostVersion: '4.0',
|
|
markups: [],
|
|
atoms: [],
|
|
cards: [],
|
|
sections: [
|
|
[1, 'p', [
|
|
[0, [], 0, '']
|
|
]]
|
|
]
|
|
};
|
|
|
|
export const CURSOR_BEFORE = -1;
|
|
export const CURSOR_AFTER = 1;
|
|
export const NO_CURSOR_MOVEMENT = 0;
|
|
|
|
// markups that should not be continued when typing and reverted to their
|
|
// text expansion style when backspacing over final char of markup
|
|
export const SPECIAL_MARKUPS = {
|
|
S: '~~',
|
|
CODE: '`',
|
|
SUP: '^',
|
|
SUB: '~'
|
|
};
|
|
|
|
export function arrayToMap(array) {
|
|
let map = Object.create(null);
|
|
array.forEach((key) => {
|
|
if (key) { // skip undefined/falsy key values
|
|
key = `is${capitalize(camelize(key))}`;
|
|
map[key] = true;
|
|
}
|
|
});
|
|
return map;
|
|
}
|
|
|
|
// if the cursor is at the end of one of our "special" markups that can only be
|
|
// toggled via markdown expansions then we want to ensure that the markup is
|
|
// removed from the edit state so that you can type without being stuck with
|
|
// the special formatting
|
|
export function toggleSpecialFormatEditState(editor) {
|
|
let {head, isCollapsed} = editor.range;
|
|
if (isCollapsed) {
|
|
Object.keys(SPECIAL_MARKUPS).forEach((tagName) => {
|
|
tagName = tagName.toLowerCase();
|
|
if (head.marker && head.marker.hasMarkup(tagName) && editor._editState.activeMarkups.findBy('tagName', tagName)) {
|
|
let nextMarker = head.markerIn(1);
|
|
if (!nextMarker || !nextMarker.hasMarkup(tagName)) {
|
|
// there is a bug somehwhere that means after pasting
|
|
// content the _editState can end up with multiple
|
|
// instances of the markup so we need to toggle all of them
|
|
editor._editState.activeMarkups.filterBy('tagName', tagName).forEach((markup) => {
|
|
editor._editState.toggleMarkupState(markup);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// helper function to insert image cards at or after the current active section
|
|
// used when pasting or dropping image files
|
|
function insertImageCards(files, postEditor) {
|
|
let {builder, editor} = postEditor;
|
|
let collection = editor.post.sections;
|
|
let section = editor.activeSection;
|
|
|
|
// when dropping an image on the editor before it's had focus there will be
|
|
// no active section so we insert the image at the end of the document
|
|
if (!section) {
|
|
section = editor.post.sections.tail;
|
|
|
|
// create a blank paragraph at the end of the document if needed because
|
|
// we use `insertSectionBefore` and don't want the image to be added
|
|
// before the last card
|
|
if (!section.isMarkerable) {
|
|
let blank = builder.createMarkupSection();
|
|
postEditor.insertSectionAtEnd(blank);
|
|
postEditor.setRange(blank.toRange());
|
|
section = postEditor._range.head.section;
|
|
}
|
|
}
|
|
|
|
// place the card after the active section
|
|
if (!section.isBlank && !section.isListItem && section.next) {
|
|
section = section.next;
|
|
}
|
|
|
|
// list items cannot contain card sections so insert a blank paragraph after
|
|
// the whole list ready to be replaced by the image cards
|
|
if (section.isListItem) {
|
|
let list = section.parent;
|
|
let blank = builder.createMarkupSection();
|
|
if (list.next) {
|
|
postEditor.insertSectionBefore(collection, blank, list.next);
|
|
} else {
|
|
postEditor.insertSectionAtEnd(blank);
|
|
}
|
|
postEditor.setRange(blank.toRange());
|
|
section = postEditor._range.head.section;
|
|
}
|
|
|
|
// insert an image card for each image, keep track of the last card to be
|
|
// inserted so that the cursor can be placed on it at the end
|
|
let lastImageSection;
|
|
files.forEach((file) => {
|
|
let payload = {
|
|
files: [file]
|
|
};
|
|
lastImageSection = builder.createCardSection('image', payload);
|
|
postEditor.insertSectionBefore(collection, lastImageSection, section);
|
|
});
|
|
|
|
// remove the current section if it's blank - avoids unexpected blank
|
|
// paragraph after the insert is complete
|
|
if (section.isBlank) {
|
|
postEditor.removeSection(section);
|
|
}
|
|
|
|
// place cursor on the last inserted image
|
|
postEditor.setRange(lastImageSection.tailPosition());
|
|
}
|
|
|
|
export default Component.extend({
|
|
feature: service(),
|
|
koenigDragDropHandler: service(),
|
|
koenigUi: service(),
|
|
|
|
tagName: 'article',
|
|
classNames: ['koenig-editor', 'w-100', 'flex-grow', 'relative', 'center', 'mb0', 'mt0'],
|
|
|
|
// public attrs
|
|
mobiledoc: null,
|
|
placeholder: 'Write here...',
|
|
autofocus: false,
|
|
spellcheck: true,
|
|
options: null,
|
|
headerOffset: 0,
|
|
dropTargetSelector: null,
|
|
scrollContainerSelector: null,
|
|
scrollOffsetTopSelector: null,
|
|
scrollOffsetBottomSelector: null,
|
|
|
|
// internal properties
|
|
editor: null,
|
|
activeMarkupTagNames: null,
|
|
activeSectionTagNames: null,
|
|
selectedRange: null,
|
|
componentAtoms: null,
|
|
componentCards: null,
|
|
linkRange: null,
|
|
selectedCard: null,
|
|
|
|
// private properties
|
|
_localMobiledoc: null,
|
|
_upstreamMobiledoc: null,
|
|
_startedRunLoop: false,
|
|
_lastIsEditingDisabled: false,
|
|
_isRenderingEditor: false,
|
|
_skipCursorChange: false,
|
|
_modifierKeys: null,
|
|
|
|
// closure actions
|
|
willCreateEditor() {},
|
|
didCreateEditor() {},
|
|
onChange() {},
|
|
cursorDidExitAtTop() {},
|
|
wordCountDidChange() {},
|
|
|
|
/* computed properties -------------------------------------------------- */
|
|
|
|
// merge in named options with any passed in `options` property data-bag
|
|
editorOptions: computed(function () {
|
|
let options = this.options || {};
|
|
let atoms = this.atoms || [];
|
|
let cards = this.cards || [];
|
|
|
|
// add our default atoms and cards, we want the defaults to be first so
|
|
// that they can be overridden by any passed-in atoms or cards.
|
|
// Use Array.concat to avoid modifying any passed in array references
|
|
atoms = defaultAtoms.concat(atoms);
|
|
cards = defaultCards.concat(cards);
|
|
|
|
return assign({
|
|
placeholder: this.placeholder,
|
|
spellcheck: this.spellcheck,
|
|
autofocus: this.autofocus,
|
|
atoms,
|
|
cards
|
|
}, options);
|
|
}),
|
|
|
|
addSnippetIfPossible: computed('saveSnippet', function () {
|
|
return this.saveSnippet ? this.addSnippet : undefined;
|
|
}),
|
|
|
|
saveCardAsSnippetIfPossible: computed('saveSnippet', function () {
|
|
return this.saveSnippet ? this.saveCardAsSnippet : undefined;
|
|
}),
|
|
|
|
/* lifecycle hooks ------------------------------------------------------ */
|
|
|
|
init() {
|
|
this._super(...arguments);
|
|
this.SPECIAL_MARKUPS = SPECIAL_MARKUPS;
|
|
|
|
// set a blank mobiledoc if we didn't receive anything
|
|
let mobiledoc = this.mobiledoc;
|
|
if (!mobiledoc) {
|
|
mobiledoc = BLANK_DOC;
|
|
this.set('mobiledoc', mobiledoc);
|
|
}
|
|
|
|
this.set('componentAtoms', A([]));
|
|
this.set('componentCards', A([]));
|
|
this.set('activeMarkupTagNames', {});
|
|
this.set('activeSectionTagNames', {});
|
|
|
|
this._modifierKeys = {
|
|
shift: false,
|
|
alt: false,
|
|
ctrl: false
|
|
};
|
|
|
|
// track mousedown/mouseup on the window rather than the ember component
|
|
// so that we're sure to get the events even when they start outside of
|
|
// this component or end outside the window.
|
|
// Mouse events are used to track when a mousebutton is down so that we
|
|
// can disable automatic cursor-in-viewport scrolling
|
|
this._onMousedownHandler = run.bind(this, this.handleMousedown);
|
|
window.addEventListener('mousedown', this._onMousedownHandler);
|
|
this._onMouseupHandler = run.bind(this, this.handleMouseup);
|
|
window.addEventListener('mouseup', this._onMouseupHandler);
|
|
|
|
this._startedRunLoop = false;
|
|
},
|
|
|
|
willRender() {
|
|
this._super(...arguments);
|
|
// use a default mobiledoc. If there are no changes then return early
|
|
let mobiledoc = this.mobiledoc || BLANK_DOC;
|
|
let mobiledocIsSame =
|
|
(this._localMobiledoc && this._localMobiledoc === mobiledoc) ||
|
|
(this._upstreamMobiledoc && this._upstreamMobiledoc === mobiledoc);
|
|
let isEditingDisabledIsSame =
|
|
this._lastIsEditingDisabled === this.isEditingDisabled;
|
|
|
|
// no change to mobiledoc, no need to recreate the editor
|
|
if (mobiledocIsSame && isEditingDisabledIsSame) {
|
|
return;
|
|
}
|
|
|
|
// update our internal references
|
|
this._lastIsEditingDisabled = this.isEditingDisabled;
|
|
this._upstreamMobiledoc = mobiledoc;
|
|
this._localMobiledoc = null;
|
|
|
|
// trigger the willCreateEditor closure action
|
|
this.willCreateEditor();
|
|
|
|
// teardown any old editor that might be around
|
|
let editor = this.editor;
|
|
if (editor) {
|
|
editor.destroy();
|
|
}
|
|
|
|
// create a new editor
|
|
let editorOptions = this.editorOptions;
|
|
editorOptions.mobiledoc = mobiledoc;
|
|
editorOptions.showLinkTooltips = false;
|
|
editorOptions.undoDepth = UNDO_DEPTH;
|
|
editorOptions.parserPlugins = createParserPlugins();
|
|
|
|
let componentHooks = {
|
|
// triggered when a card section is added to the mobiledoc
|
|
[ADD_CARD_HOOK]: ({env, options, payload}, koenigOptions) => {
|
|
let cardName = env.name;
|
|
let componentName = CARD_COMPONENT_MAP[cardName];
|
|
|
|
// the payload must be copied to avoid sharing the reference.
|
|
// `payload.files` is special because it's set by paste/drag-n-drop
|
|
// events and can't be copied for security reasons
|
|
let {files} = payload;
|
|
let payloadCopy = new TrackedObject(JSON.parse(JSON.stringify(payload || null)));
|
|
payloadCopy.files = files;
|
|
|
|
// all of the properties that will be passed through to the
|
|
// component cards via the template
|
|
let card = EmberObject.create({
|
|
cardName,
|
|
componentName,
|
|
koenigOptions,
|
|
payload: payloadCopy,
|
|
env,
|
|
options,
|
|
editor,
|
|
postModel: env.postModel,
|
|
isSelected: false,
|
|
isEditing: false
|
|
});
|
|
|
|
// the desination element is the container that gets rendered
|
|
// inside the editor, once rendered we use {{in-element}} to
|
|
// wormhole in the actual ember component
|
|
let cardId = guidFor(card);
|
|
let destinationElementId = `koenig-editor-card-${cardId}`;
|
|
let destinationElement = document.createElement('div');
|
|
destinationElement.id = destinationElementId;
|
|
|
|
card.setProperties({
|
|
destinationElementId,
|
|
destinationElement
|
|
});
|
|
|
|
// after render we render the full ember card via {{in-element}}
|
|
run.schedule('afterRender', () => {
|
|
this.componentCards.pushObject(card);
|
|
});
|
|
|
|
// render the destination element inside the editor
|
|
return {card, element: destinationElement};
|
|
},
|
|
// triggered when a card section is removed from the mobiledoc
|
|
[REMOVE_CARD_HOOK]: (card) => {
|
|
this.componentCards.removeObject(card);
|
|
},
|
|
[ADD_ATOM_HOOK]: ({env, options, value, payload}) => {
|
|
const atomName = env.name;
|
|
const componentName = ATOM_COMPONENT_MAP[atomName];
|
|
|
|
const payloadCopy = new TrackedObject(JSON.parse(JSON.stringify(payload || null)));
|
|
|
|
const atom = EmberObject.create({
|
|
atomName,
|
|
componentName,
|
|
value,
|
|
payload: payloadCopy,
|
|
env,
|
|
options,
|
|
editor
|
|
});
|
|
|
|
// the desination element is the container that gets rendered
|
|
// inside the editor, once rendered we use {{in-element}} to
|
|
// wormhole in the actual ember component
|
|
let atomId = guidFor(atom);
|
|
let destinationElementId = `koenig-editor-atom-${atomId}`;
|
|
let destinationElement = document.createElement('div');
|
|
destinationElement.id = destinationElementId;
|
|
destinationElement.classList.add('dib');
|
|
|
|
atom.setProperties({
|
|
destinationElementId,
|
|
destinationElement
|
|
});
|
|
|
|
run.schedule('afterRender', () => {
|
|
this.componentAtoms.pushObject(atom);
|
|
});
|
|
|
|
// render the destination element inside the editor
|
|
return {atom, element: destinationElement};
|
|
},
|
|
[REMOVE_ATOM_HOOK]: (atom) => {
|
|
this.componentAtoms.removeObject(atom);
|
|
}
|
|
};
|
|
editorOptions.cardOptions = Object.assign({}, this.cardOptions, componentHooks);
|
|
|
|
editor = new Editor(editorOptions);
|
|
|
|
// set up key commands and text expansions (MD conversion)
|
|
// TODO: this will override any passed in options, we should allow the
|
|
// default behaviour to be overridden by addon consumers
|
|
registerKeyCommands(editor, this);
|
|
registerTextExpansions(editor, this);
|
|
|
|
// set up editor hooks
|
|
editor.willRender(() => {
|
|
// The editor's render/rerender will happen after this `editor.willRender`,
|
|
// so we explicitly start a runloop here if there is none, so that the
|
|
// add/remove card hooks happen inside a runloop.
|
|
// When pasting text that gets turned into a card, for example,
|
|
// the add card hook would run outside the runloop if we didn't begin a new
|
|
// one now.
|
|
if (!run.currentRunLoop) {
|
|
this._startedRunLoop = true;
|
|
run.begin();
|
|
}
|
|
});
|
|
|
|
editor.didRender(() => {
|
|
// if we had explicitly started a runloop in `editor.willRender`,
|
|
// we must explicitly end it here
|
|
if (this._startedRunLoop) {
|
|
this._startedRunLoop = false;
|
|
run.end();
|
|
}
|
|
|
|
if (this._cleanupScheduled) {
|
|
run.schedule('afterRender', this, this._cleanup);
|
|
}
|
|
});
|
|
|
|
editor.postDidChange(() => {
|
|
run.join(() => {
|
|
this.postDidChange(editor);
|
|
});
|
|
});
|
|
|
|
editor.cursorDidChange(() => {
|
|
run.join(() => {
|
|
this.cursorDidChange(editor);
|
|
});
|
|
});
|
|
|
|
editor.inputModeDidChange(() => {
|
|
if (this.isDestroyed) {
|
|
return;
|
|
}
|
|
run.join(() => {
|
|
this.inputModeDidChange(editor);
|
|
});
|
|
});
|
|
|
|
editor.willHandleNewline((event) => {
|
|
run.join(() => {
|
|
this.willHandleNewline(event);
|
|
});
|
|
});
|
|
|
|
if (this.isEditingDisabled) {
|
|
editor.disableEditing();
|
|
}
|
|
|
|
this.set('editor', editor);
|
|
this.didCreateEditor(this);
|
|
|
|
run.schedule('afterRender', this, this._registerCardReorderDragDropHandler);
|
|
run.schedule('afterRender', this, this._calculateWordCount);
|
|
},
|
|
|
|
didInsertElement() {
|
|
this._super(...arguments);
|
|
let editorElement = this.element.querySelector('[data-kg="editor"]');
|
|
|
|
this._pasteHandler = run.bind(this, this.handlePaste);
|
|
editorElement.addEventListener('paste', this._pasteHandler);
|
|
|
|
if (this.scrollContainerSelector) {
|
|
this._scrollContainer = document.querySelector(this.scrollContainerSelector);
|
|
}
|
|
|
|
this._keydownHandler = run.bind(this, this.handleKeydown);
|
|
window.addEventListener('keydown', this._keydownHandler);
|
|
this._keyupHandler = run.bind(this, this.handleKeyup);
|
|
window.addEventListener('keyup', this._keyupHandler);
|
|
|
|
this._dropTarget = document.querySelector(this.dropTargetSelector) || this.element;
|
|
this._dragOverHandler = run.bind(this, this.handleDragOver);
|
|
this._dragLeaveHandler = run.bind(this, this.handleDragLeave);
|
|
this._dropHandler = run.bind(this, this.handleDrop);
|
|
this._dropTarget.addEventListener('dragover', this._dragOverHandler);
|
|
this._dropTarget.addEventListener('dragleave', this._dragLeaveHandler);
|
|
this._dropTarget.addEventListener('drop', this._dropHandler);
|
|
},
|
|
|
|
// our ember component has rendered, now we need to render the mobiledoc
|
|
// editor itself if necessary
|
|
didRender() {
|
|
this._super(...arguments);
|
|
let editor = this.editor;
|
|
if (!editor.hasRendered) {
|
|
let editorElement = this.element.querySelector('[data-kg="editor"]');
|
|
this._isRenderingEditor = true;
|
|
editor.render(editorElement);
|
|
this._isRenderingEditor = false;
|
|
}
|
|
},
|
|
|
|
willDestroyElement() {
|
|
this._super(...arguments);
|
|
|
|
let {editor, _dropTarget, _cardDragDropContainer} = this;
|
|
|
|
_dropTarget.removeEventListener('dragover', this._dragOverHandler);
|
|
_dropTarget.removeEventListener('dragleave', this._dragLeaveHandler);
|
|
_dropTarget.removeEventListener('drop', this._dropHandler);
|
|
|
|
window.removeEventListener('keydown', this._keydownHandler);
|
|
window.removeEventListener('keyup', this._keyupHandler);
|
|
|
|
let editorElement = this.element.querySelector('[data-kg="editor"]');
|
|
editorElement.removeEventListener('paste', this._pasteHandler);
|
|
|
|
_cardDragDropContainer.destroy();
|
|
|
|
editor.destroy();
|
|
|
|
this._super(...arguments);
|
|
},
|
|
|
|
actions: {
|
|
exitCursorAtTop() {
|
|
if (this.selectedCard) {
|
|
this.deselectCard(this.selectedCard);
|
|
}
|
|
|
|
this.cursorDidExitAtTop();
|
|
},
|
|
|
|
toggleMarkup(markupTagName, postEditor) {
|
|
(postEditor || this.editor).toggleMarkup(markupTagName);
|
|
},
|
|
|
|
toggleSection(sectionTagName, postEditor) {
|
|
(postEditor || this.editor).toggleSection(sectionTagName);
|
|
},
|
|
|
|
toggleHeaderSection(headingTagName, postEditor, options = {}) {
|
|
let editor = this.editor;
|
|
|
|
// skip toggle if we already have the same heading level
|
|
if (!options.force && editor.activeSection.tagName === headingTagName) {
|
|
return;
|
|
}
|
|
|
|
let operation = function (operationPostEditor) {
|
|
// strip all formatting aside from links
|
|
operationPostEditor.removeMarkupFromRange(
|
|
editor.activeSection.toRange(),
|
|
m => m.tagName !== 'a'
|
|
);
|
|
|
|
operationPostEditor.toggleSection(headingTagName);
|
|
};
|
|
|
|
this._performEdit(operation, postEditor);
|
|
},
|
|
|
|
replaceWithCardSection(cardName, range, payload) {
|
|
let editor = this.editor;
|
|
let {head: {section}} = range;
|
|
|
|
editor.run((postEditor) => {
|
|
let {builder} = postEditor;
|
|
let card = builder.createCardSection(cardName, payload);
|
|
let nextSection = section.next;
|
|
let needsTrailingParagraph = !nextSection;
|
|
|
|
postEditor.replaceSection(section, card);
|
|
|
|
// add an empty paragraph after if necessary so writing can continue
|
|
if (needsTrailingParagraph) {
|
|
let newSection = postEditor.builder.createMarkupSection('p');
|
|
postEditor.insertSectionAtEnd(newSection);
|
|
postEditor.setRange(newSection.tailPosition());
|
|
} else {
|
|
postEditor.setRange(nextSection.headPosition());
|
|
}
|
|
});
|
|
|
|
// cards are pushed on to the `componentCards` array so we can
|
|
// assume that the last card in the list is the one we want to
|
|
// select. Needs to be scheduled afterRender so that the new card
|
|
// is actually present
|
|
const editOrSelectCard = (card) => {
|
|
if (card.koenigOptions.hasEditMode) {
|
|
this.editCard(card);
|
|
} else if (card.koenigOptions.selectAfterInsert) {
|
|
this.selectCard(card);
|
|
}
|
|
};
|
|
|
|
run.schedule('afterRender', this, function () {
|
|
let card = this.componentCards.lastObject;
|
|
|
|
// Sentry was showing `card` being undefined at times (id: 2451728694).
|
|
// Retrying with logging to see if it's a case of multiple render loops
|
|
// or some other underlying issue
|
|
// TODO: check Sentry for issue occurence after 4.13.0
|
|
if (!card) {
|
|
captureMessage('replaceWithCardSection: card was not present after first render');
|
|
console.warn('replaceWithCardSection: card was not present after first render'); // eslint-disable-line
|
|
|
|
run.schedule('afterRender', this, function () {
|
|
card = this.componentCards.lastObject;
|
|
|
|
if (!card) {
|
|
captureMessage('replaceWithCardSection: card was not present after second render');
|
|
console.warn('replaceWithCardSection: card was not present after second render'); // eslint-disable-line
|
|
}
|
|
|
|
editOrSelectCard(card);
|
|
});
|
|
}
|
|
|
|
editOrSelectCard(card);
|
|
});
|
|
},
|
|
|
|
replaceWithPost(range, post) {
|
|
let {editor} = this;
|
|
let {head: {section}} = range;
|
|
|
|
editor.selectRange(range);
|
|
|
|
editor.run((postEditor) => {
|
|
let nextPosition = postEditor.deleteRange(section.toRange());
|
|
postEditor.setRange(nextPosition);
|
|
|
|
let blankSection = postEditor.builder.createMarkupSection('p');
|
|
postEditor.insertSectionBefore(editor.post.sections, blankSection);
|
|
postEditor.setRange(blankSection.toRange());
|
|
|
|
nextPosition = postEditor.insertPost(editor.range.head, post);
|
|
postEditor.setRange(nextPosition);
|
|
});
|
|
},
|
|
|
|
selectCard(card) {
|
|
this.selectCard(card);
|
|
},
|
|
|
|
editCard(card) {
|
|
this.editCard(card);
|
|
},
|
|
|
|
deselectCard(card) {
|
|
this.deselectCard(card);
|
|
},
|
|
|
|
// range should be set to the full extent of the selection or the
|
|
// appropriate <a> markup. If there's a selection when the link edit
|
|
// component renders it will re-select when finished which should
|
|
// trigger the normal toolbar
|
|
editLink(range, rect) {
|
|
let linkMarkup = getLinkMarkupFromRange(range);
|
|
if ((!range.isCollapsed || linkMarkup) && range.headSection.isMarkerable) {
|
|
this.set('linkRange', range);
|
|
this.set('linkRect', rect);
|
|
}
|
|
},
|
|
|
|
cancelEditLink() {
|
|
this.set('linkRange', null);
|
|
this.set('linkRect', null);
|
|
},
|
|
|
|
deleteCard(card, cursorMovement = CURSOR_AFTER) {
|
|
this.deleteCard(card, cursorMovement);
|
|
},
|
|
|
|
moveCursorToPrevSection(card) {
|
|
let section = this.getSectionFromCard(card);
|
|
|
|
if (section.prev) {
|
|
this.deselectCard(card);
|
|
this.moveCaretToTailOfSection(section.prev, false);
|
|
}
|
|
},
|
|
|
|
moveCursorToNextSection(card) {
|
|
let section = this.getSectionFromCard(card);
|
|
|
|
if (section.next) {
|
|
this.deselectCard(card);
|
|
this.moveCaretToHeadOfSection(section.next, false);
|
|
} else {
|
|
this.send('addParagraphAfterCard', card);
|
|
}
|
|
},
|
|
|
|
addParagraphAfterCard(card) {
|
|
let editor = this.editor;
|
|
let section = this.getSectionFromCard(card);
|
|
let collection = section.parent.sections;
|
|
let nextSection = section.next;
|
|
|
|
this.deselectCard(card);
|
|
|
|
editor.run((postEditor) => {
|
|
let {builder} = postEditor;
|
|
let newPara = builder.createMarkupSection('p');
|
|
|
|
if (nextSection) {
|
|
postEditor.insertSectionBefore(collection, newPara, nextSection);
|
|
} else {
|
|
postEditor.insertSectionAtEnd(newPara);
|
|
}
|
|
|
|
postEditor.setRange(newPara.tailPosition());
|
|
});
|
|
}
|
|
},
|
|
|
|
addSnippet: action(function (event) {
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
|
|
let {selectedRange} = this;
|
|
|
|
if (selectedRange.isCollapsed) {
|
|
return;
|
|
}
|
|
|
|
this.set('snippetRect', null);
|
|
this.set('snippetRange', selectedRange);
|
|
}),
|
|
|
|
saveCardAsSnippet: action(function (card) {
|
|
let section = this.getSectionFromCard(card);
|
|
this.set('snippetRect', card.component.element.getBoundingClientRect());
|
|
this.set('snippetRange', section.toRange());
|
|
}),
|
|
|
|
cancelAddSnippet: action(function () {
|
|
this.set('snippetRange', null);
|
|
this.set('snippetRect', null);
|
|
}),
|
|
|
|
/* public interface ----------------------------------------------------- */
|
|
// TODO: find a better way to expose the public interface?
|
|
|
|
skipNewline() {
|
|
this._skipNextNewline = true;
|
|
},
|
|
|
|
// HACK: this scheduled cleanup is a bit hacky. We call .cleanup when
|
|
// initializing Koenig in our editor controller but we have to wait for
|
|
// rendering to finish so that componentCards is populated, even then
|
|
// it's unlikely the card.component registration has finished.
|
|
//
|
|
// TODO: see if there's a way we can perform cleanup directly on the
|
|
// mobiledoc, maybe with a "cleanupOnInit" option so that we modify the
|
|
// mobiledoc before we start rendering
|
|
cleanup() {
|
|
this._cleanupScheduled = true;
|
|
},
|
|
|
|
_cleanup() {
|
|
this.componentCards.forEach((card) => {
|
|
let shouldDelete = card.koenigOptions.deleteIfEmpty;
|
|
|
|
if (!shouldDelete) {
|
|
return;
|
|
}
|
|
|
|
if (typeof shouldDelete === 'string') {
|
|
let payloadKey = shouldDelete;
|
|
shouldDelete = cardToDelete => isBlank(get(cardToDelete, payloadKey));
|
|
}
|
|
|
|
if (shouldDelete(card)) {
|
|
this.deleteCard(card, NO_CURSOR_MOVEMENT);
|
|
}
|
|
});
|
|
this._cleanupScheduled = false;
|
|
},
|
|
|
|
/* mobiledoc event handlers --------------------------------------------- */
|
|
|
|
postDidChange(editor) {
|
|
let updatedMobiledoc = editor.serialize(MOBILEDOC_VERSION);
|
|
|
|
// mobiledoc-kit will not output any custom top-level properties so we
|
|
// need to add them back in here
|
|
updatedMobiledoc.ghostVersion = this._upstreamMobiledoc.ghostVersion || BLANK_DOC.ghostVersion;
|
|
|
|
this._localMobiledoc = updatedMobiledoc;
|
|
|
|
// trigger closure action
|
|
this.onChange(updatedMobiledoc);
|
|
|
|
// re-calculate word count
|
|
this._calculateWordCount();
|
|
|
|
// refresh drag/drop
|
|
// TODO: can be made more performant by only refreshing when droppable
|
|
// order changes or when sections are added/removed
|
|
this._cardDragDropContainer.refresh();
|
|
},
|
|
|
|
cursorDidChange(editor) {
|
|
let {head, tail, direction, isCollapsed, head: {section}} = editor.range;
|
|
|
|
// sometimes we perform a programatic edit that causes a cursor change
|
|
// but we actually want to skip the default behaviour because we've
|
|
// already handled it, e.g. on card insertion, manual card selection
|
|
if (this._skipCursorChange) {
|
|
this._skipCursorChange = false;
|
|
this.set('selectedRange', editor.range);
|
|
this._scrollCursorIntoView();
|
|
return;
|
|
}
|
|
|
|
// ignore the cursor moving from one end to the other within a selected
|
|
// card section, clicking and other interactions within a card can cause
|
|
// this to happen and we don't want to select/deselect accidentally.
|
|
// See the up/down/left/right key handlers for the card selection
|
|
if (this.selectedCard && this.selectedCard.postModel === section) {
|
|
return;
|
|
}
|
|
|
|
// select the card if the cursor is on the before/after ‌ char
|
|
if (section && isCollapsed && section.type === 'card-section') {
|
|
if (head.offset === 0 || head.offset === 1) {
|
|
// select card after render to ensure that our componentCards
|
|
// attr is populated
|
|
run.schedule('afterRender', this, () => {
|
|
let card = this.getCardFromSection(section);
|
|
this.selectCard(card);
|
|
this.set('selectedRange', editor.range);
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// deselect any selected card because the cursor is no longer on a card
|
|
if (this.selectedCard && !editor.range.isBlank) {
|
|
this.deselectCard(this.selectedCard);
|
|
}
|
|
|
|
// if we have `code` or ~strike~ formatting to the left but not the right
|
|
// then toggle the formatting - these formats should only be creatable
|
|
// through the text expansions
|
|
toggleSpecialFormatEditState(editor);
|
|
|
|
// do not include the tail section if it's offset is 0
|
|
// fixes triple-click unexpectedly selecting two sections for section-level formatting
|
|
// https://github.com/bustle/mobiledoc-kit/issues/597
|
|
if (direction === 1 && !isCollapsed && tail.offset === 0 && tail.section.prev) {
|
|
let finalSection = tail.section.prev;
|
|
let newRange = new MobiledocRange(head, finalSection.tailPosition());
|
|
return editor.selectRange(newRange);
|
|
}
|
|
|
|
// pass the selected range through to the toolbar + menu components
|
|
this.set('selectedRange', editor.range);
|
|
this._scrollCursorIntoView();
|
|
},
|
|
|
|
// fired when the active section(s) or markup(s) at the current cursor
|
|
// position or selection have changed. We use this event to update the
|
|
// activeMarkup/section tag lists which control button states in our popup
|
|
// toolbar
|
|
inputModeDidChange(editor) {
|
|
let markupTags = arrayToMap(editor.activeMarkups.map(m => m.tagName));
|
|
// editor.activeSections are leaf sections.
|
|
// Map parent section tag names (e.g. 'p', 'ul', 'ol') so that list buttons
|
|
// are updated.
|
|
// eslint-disable-next-line no-confusing-arrow
|
|
let sectionParentTagNames = editor.activeSections.map(s => s.isNested ? s.parent.tagName : s.tagName);
|
|
let sectionTags = arrayToMap(sectionParentTagNames);
|
|
|
|
// On keyboard cursor movement our `cursorDidChange` toggle for special
|
|
// formats happens before mobiledoc's readstate updates the edit states
|
|
// so we have to re-do it here
|
|
// TODO: can we make the event order consistent in mobiledoc-kit?
|
|
toggleSpecialFormatEditState(editor);
|
|
|
|
// Avoid updating this component's properties synchronously while
|
|
// rendering the editor (after rendering the component) because it
|
|
// causes Ember to display deprecation warnings
|
|
if (this._isRenderingEditor) {
|
|
run.schedule('afterRender', () => {
|
|
this.set('activeMarkupTagNames', markupTags);
|
|
this.set('activeSectionTagNames', sectionTags);
|
|
});
|
|
} else {
|
|
this.set('activeMarkupTagNames', markupTags);
|
|
this.set('activeSectionTagNames', sectionTags);
|
|
}
|
|
},
|
|
|
|
willHandleNewline(event) {
|
|
if (this._skipNextNewline) {
|
|
event.preventDefault();
|
|
this._skipNextNewline = false;
|
|
}
|
|
},
|
|
|
|
/* custom event handlers ------------------------------------------------ */
|
|
|
|
// we keep track of the modifier keys that are pressed so that in other event
|
|
// handlers we can adjust the behaviour. Necessary because the browser doesn't
|
|
// natively provide any info on non-key events about which keys are pressed.
|
|
//
|
|
// German keyboard layouts use a dead key for the ` char so it doesn't
|
|
// fire keypress events. We watch for the event triggered when pressing
|
|
// spacebar to "finalise" the backtick input then call the text input
|
|
// handlers manually instead.
|
|
//
|
|
// Does not work on Linux but it's easier to have keymaps without dead keys there
|
|
//
|
|
// Secondarily, we also use this handler to deal with known default key combos
|
|
// that perform actions like DELETE, BACKSPACE, etc which can break mobiledoc
|
|
// if not intercepted and handled like the "normal" key events
|
|
handleKeydown(event) {
|
|
let key = Key.fromEvent(event);
|
|
this._updateModifiersFromKey(key, {isDown: true});
|
|
|
|
if (event.key === 'Dead' && event.keyCode === 192) {
|
|
return this._isGraveInput = true;
|
|
}
|
|
|
|
this._isGraveInput = false;
|
|
|
|
// Chrome/Safari can be matched immediately on keydown unlike Firefox
|
|
if (event.key === '`' && event.code === 'Space') {
|
|
this._triggerTextHandlers();
|
|
}
|
|
|
|
// intercept and simulate keyboard events to be picked up by
|
|
// mobiledoc-kit's event manager
|
|
// https://github.com/TryGhost/Ghost/issues/10240
|
|
let {editor} = this;
|
|
if (Browser.isMac() && editor && editor.cursor && editor.cursor.isAddressable(event.target)) {
|
|
// ctrl+h = BACKSPACE
|
|
if (event.key === 'h' && event.ctrlKey) {
|
|
event.preventDefault();
|
|
let simEvent = new KeyboardEvent('keydown', {
|
|
key: 'Backspace',
|
|
keyCode: 8
|
|
});
|
|
event.target.dispatchEvent(simEvent);
|
|
}
|
|
|
|
// ctrl+d = DELETE
|
|
if (event.key === 'd' && event.ctrlKey) {
|
|
event.preventDefault();
|
|
let simEvent = new KeyboardEvent('keydown', {
|
|
key: 'Delete',
|
|
keyCode: 46
|
|
});
|
|
event.target.dispatchEvent(simEvent);
|
|
}
|
|
}
|
|
},
|
|
|
|
handleKeyup(event) {
|
|
let key = Key.fromEvent(event);
|
|
this._updateModifiersFromKey(key, {isDown: false});
|
|
|
|
if (this._isGraveInput && event.key === ' ') {
|
|
this._isGraveInput = false;
|
|
this._triggerTextHandlers();
|
|
}
|
|
},
|
|
|
|
handlePaste(event) {
|
|
let {editor} = this;
|
|
|
|
// don't trigger our paste handling for pastes within cards or outside
|
|
// of the editor canvas. Avoids double-paste of content when pasting
|
|
// into cards
|
|
if (!editor.cursor.isAddressable(event.target)) {
|
|
return;
|
|
}
|
|
|
|
// if we have image files pasted, create an image card for each and set
|
|
// the payload.files property which will cause the image to be auto-uploaded
|
|
// NOTE: browser support varies as of May 2018:
|
|
// - Safari: will paste all images
|
|
// - Chrome: will only paste the first image
|
|
// - Firefox: will not paste any images
|
|
let images = Array.from(event.clipboardData.files).filter(file => file.type.indexOf('image') > -1);
|
|
if (images.length > 0) {
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
|
|
editor.run((postEditor) => {
|
|
insertImageCards(images, postEditor);
|
|
});
|
|
return;
|
|
}
|
|
|
|
let range = editor.range;
|
|
let {html, text} = getContentFromPasteEvent(event);
|
|
|
|
if (text && validator.isURL(text)) {
|
|
// if we have a text selection, make that selection a link
|
|
if (range && !range.isCollapsed && range.headSection === range.tailSection && range.headSection.isMarkerable) {
|
|
let linkMarkup = editor.builder.createMarkup('a', {href: text});
|
|
editor.run((postEditor) => {
|
|
postEditor.addMarkupToRange(range, linkMarkup);
|
|
});
|
|
editor.selectRange(range.tail);
|
|
|
|
// prevent mobiledoc's default paste event handler firing
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
return;
|
|
}
|
|
|
|
// if there's no selection and cursor is on an empty paragraph,
|
|
// insert the url as an embed card, unless SHIFT is pressed. Setting
|
|
// the `linkOnError` option results in an immediate switch to a
|
|
// plain link if the embed fails for any reason (eg, unknown provider)
|
|
if (range && range.isCollapsed && range.headSection.isBlank && !range.headSection.isListItem) {
|
|
if (!this._modifierKeys.shift) {
|
|
editor.run((postEditor) => {
|
|
let payload = new TrackedObject({url: text, linkOnError: true, isDirectUrl: true});
|
|
let card = postEditor.builder.createCardSection('embed', payload);
|
|
let nextSection = range.headSection.next;
|
|
|
|
postEditor.replaceSection(range.headSection, card);
|
|
|
|
// move caret to the next section, creating a blank one
|
|
// if none exists
|
|
if (nextSection) {
|
|
postEditor.setRange(nextSection.headPosition());
|
|
} else {
|
|
let newSection = postEditor.builder.createMarkupSection('p');
|
|
postEditor.insertSectionAtEnd(newSection);
|
|
postEditor.setRange(newSection.headPosition());
|
|
}
|
|
});
|
|
} else {
|
|
// ensure the pasted URL is still auto-linked when Shift is pressed
|
|
editor.run((postEditor) => {
|
|
let linkMarkup = editor.builder.createMarkup('a', {href: text});
|
|
postEditor.insertTextWithMarkup(range.head, text, [linkMarkup]);
|
|
});
|
|
}
|
|
|
|
// prevent mobiledoc's default paste event handler firing
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// if plain text is pasted we run it through our markdown parser so that
|
|
// we get better output than mobiledoc's default text parsing and we can
|
|
// provide an easier MD->Mobiledoc conversion route
|
|
// NOTE: will not work in Edge which only ever exposes `html`
|
|
if (text && !html && !this._modifierKeys.shift) {
|
|
// prevent mobiledoc's default paste event handler firing
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
|
|
// we can't modify the paste event itself so we trigger a mock
|
|
// paste event with our own data
|
|
let pasteEvent = {
|
|
type: 'paste',
|
|
preventDefault() {},
|
|
target: editor.element,
|
|
clipboardData: {
|
|
getData(type) {
|
|
if (type === 'text/html') {
|
|
return formatMarkdown(text, false);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
editor.triggerEvent(editor.element, 'paste', pasteEvent);
|
|
}
|
|
|
|
// we need to standardise HTML here because parserPlugins do not get
|
|
// passed inline markup such as `<b>` or `<i>`
|
|
if (html) {
|
|
// prevent mobiledoc's default paste event handler firing
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
|
|
let normalizedHtml = html
|
|
.replace(/<b(\s|>)/gi, '<strong$1')
|
|
.replace(/<\/b>/gi, '</strong>')
|
|
.replace(/<i(\s|>)/gi, '<em$1')
|
|
.replace(/<\/i>/gi, '</em>');
|
|
|
|
// we can't modify the paste event itself so we trigger a mock
|
|
// paste event with our own data
|
|
let pasteEvent = {
|
|
type: 'paste',
|
|
preventDefault() {},
|
|
target: editor.element,
|
|
clipboardData: {
|
|
getData(type) {
|
|
if (type === 'text/plain') {
|
|
return text;
|
|
}
|
|
if (type === 'text/html') {
|
|
return normalizedHtml;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
editor.triggerEvent(editor.element, 'paste', pasteEvent);
|
|
}
|
|
},
|
|
|
|
handleMousedown(event) {
|
|
// we only care about the left mouse button
|
|
if (event.which === 1) {
|
|
this._isMouseDown = true;
|
|
}
|
|
},
|
|
|
|
handleMouseup(event) {
|
|
if (event.which === 1) {
|
|
this._isMouseDown = false;
|
|
}
|
|
},
|
|
|
|
handleDragOver(event) {
|
|
if (!event.dataTransfer || event.target.closest('.__mobiledoc-card')) {
|
|
return;
|
|
}
|
|
|
|
// this is needed to work around inconsistencies with dropping files
|
|
// from Chrome's downloads bar
|
|
if (navigator.userAgent.indexOf('Chrome') > -1) {
|
|
let eA = event.dataTransfer.effectAllowed;
|
|
event.dataTransfer.dropEffect = (eA === 'move' || eA === 'linkMove') ? 'move' : 'copy';
|
|
}
|
|
|
|
// indicate to the browser that we want to handle drop behaviour here
|
|
event.stopPropagation();
|
|
event.preventDefault();
|
|
},
|
|
|
|
handleDragLeave(event) {
|
|
event.preventDefault();
|
|
},
|
|
|
|
handleDrop(event) {
|
|
// drops on cards that are in an edit state should be cancelled
|
|
// editable cards should handle drag-n-drop themselves if needed
|
|
let cardElem = event.target.closest('.__mobiledoc-card');
|
|
if (cardElem) {
|
|
let cardId = cardElem.firstChild.id;
|
|
let card = this.componentCards.findBy('destinationElementId', cardId);
|
|
if (card.isEditing || card.component.handlesDragDrop) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
if (event.dataTransfer.files) {
|
|
let images = Array.from(event.dataTransfer.files).filter(file => file.type.indexOf('image') > -1);
|
|
if (images.length > 0) {
|
|
this.editor.run((postEditor) => {
|
|
insertImageCards(images, postEditor);
|
|
});
|
|
this._scrollCursorIntoView({jumpToCard: true});
|
|
}
|
|
}
|
|
},
|
|
|
|
/* Ember event handlers ------------------------------------------------- */
|
|
|
|
// disable dragging
|
|
dragStart(event) {
|
|
event.preventDefault();
|
|
},
|
|
|
|
/* public methods ------------------------------------------------------- */
|
|
|
|
selectCard(card, isEditing = false) {
|
|
// no-op if card is already selected
|
|
if (card === this.selectedCard && isEditing === card.isEditing) {
|
|
return;
|
|
}
|
|
|
|
// deselect any already selected card
|
|
if (this.selectedCard && card !== this.selectedCard) {
|
|
this.deselectCard(this.selectedCard);
|
|
}
|
|
|
|
// setting a card as selected trigger's the cards didReceiveAttrs
|
|
// hook where the actual selection state change happens. Put into edit
|
|
// mode if necessary
|
|
card.setProperties({
|
|
isEditing,
|
|
isSelected: true
|
|
});
|
|
this.selectedCard = card;
|
|
|
|
// hide the cursor and place it after the card so that ENTER can
|
|
// create a new paragraph and cursorDidExitAtTop gets fired on LEFT
|
|
// if the card is at the top of the document
|
|
this._hideCursor();
|
|
let section = this.getSectionFromCard(card);
|
|
this.moveCaretToTailOfSection(section);
|
|
},
|
|
|
|
editCard(card) {
|
|
// no-op if card is already being edited
|
|
if (card === this.selectedCard && card.isEditing) {
|
|
return;
|
|
}
|
|
|
|
// select the card with edit mode
|
|
this.selectCard(card, true);
|
|
|
|
this._cardDragDropContainer.disableDrag();
|
|
},
|
|
|
|
deselectCard(card) {
|
|
card.set('isEditing', false);
|
|
card.set('isSelected', false);
|
|
this.selectedCard = null;
|
|
this._showCursor();
|
|
this._cardDragDropContainer.enableDrag();
|
|
},
|
|
|
|
deleteCard(card, cursorDirection) {
|
|
this.editor.run((postEditor) => {
|
|
let section = card.env.postModel;
|
|
let nextPosition;
|
|
|
|
if (cursorDirection === CURSOR_BEFORE) {
|
|
nextPosition = section.prev && section.prev.tailPosition();
|
|
} else {
|
|
nextPosition = section.next && section.next.headPosition();
|
|
}
|
|
|
|
postEditor.removeSection(section);
|
|
|
|
// if there's no prev or next section then the doc is empty, we want
|
|
// to add a blank paragraph and place the cursor in it
|
|
if (cursorDirection !== NO_CURSOR_MOVEMENT && !nextPosition) {
|
|
let {builder} = postEditor;
|
|
let newPara = builder.createMarkupSection('p');
|
|
postEditor.insertSectionAtEnd(newPara);
|
|
return postEditor.setRange(newPara.tailPosition());
|
|
}
|
|
|
|
if (cursorDirection !== NO_CURSOR_MOVEMENT) {
|
|
return postEditor.setRange(nextPosition);
|
|
}
|
|
});
|
|
},
|
|
|
|
getCardFromSection(section) {
|
|
if (!section || section.type !== 'card-section') {
|
|
return;
|
|
}
|
|
|
|
let cardId = section.renderNode.element.querySelector('.__mobiledoc-card').firstChild.id;
|
|
|
|
return this.componentCards.findBy('destinationElementId', cardId);
|
|
},
|
|
|
|
getCardFromElement(element) {
|
|
if (!element) {
|
|
return;
|
|
}
|
|
|
|
let cardElement = element.querySelector('.__mobiledoc-card') || getParent(element, '.__mobiledoc-card');
|
|
|
|
if (!cardElement) {
|
|
return;
|
|
}
|
|
|
|
let cardId = cardElement.firstChild?.id;
|
|
|
|
if (cardId) {
|
|
return this.componentCards.findBy('destinationElementId', cardId);
|
|
}
|
|
},
|
|
|
|
getSectionFromCard(card) {
|
|
return card.env.postModel;
|
|
},
|
|
|
|
moveCaretToHeadOfSection(section, skipCursorChange = true) {
|
|
this.moveCaretToSection(section, 'head', skipCursorChange);
|
|
},
|
|
|
|
moveCaretToTailOfSection(section, skipCursorChange = true) {
|
|
this.moveCaretToSection(section, 'tail', skipCursorChange);
|
|
},
|
|
|
|
moveCaretToSection(section, position, skipCursorChange = true) {
|
|
let sectionPosition = position === 'head' ? section.headPosition() : section.tailPosition();
|
|
let range = sectionPosition.toRange();
|
|
|
|
// don't trigger another cursor change selection after selecting
|
|
if (skipCursorChange && !range.isEqual(this.editor.range)) {
|
|
this._skipCursorChange = true;
|
|
}
|
|
|
|
this.editor.selectRange(range);
|
|
},
|
|
|
|
/* internal methods ----------------------------------------------------- */
|
|
|
|
// nested editor.run loops will create additional undo steps so this is a
|
|
// shortcut for when we already have a postEditor
|
|
_performEdit(editOperation, postEditor) {
|
|
if (postEditor) {
|
|
editOperation(postEditor);
|
|
} else {
|
|
this.editor.run((operationPostEditor) => {
|
|
editOperation(operationPostEditor);
|
|
});
|
|
}
|
|
},
|
|
|
|
_hideCursor() {
|
|
this.editor.element.style.caretColor = 'transparent';
|
|
},
|
|
|
|
_showCursor() {
|
|
this.editor.element.style.caretColor = 'auto';
|
|
},
|
|
|
|
_updateModifiersFromKey(key, {isDown}) {
|
|
if (key.isShiftKey()) {
|
|
this._modifierKeys.shift = isDown;
|
|
} else if (key.isAltKey()) {
|
|
this._modifierKeys.alt = isDown;
|
|
} else if (key.isCtrlKey()) {
|
|
this._modifierKeys.ctrl = isDown;
|
|
}
|
|
},
|
|
|
|
_scrollCursorIntoView(options = {jumpToCard: false}) {
|
|
// disable auto-scroll if the mouse or shift key is being used to create
|
|
// a selection - the browser handles scrolling well in this case
|
|
if (!this._scrollContainer || this._isMouseDown || this._modifierKeys.shift) {
|
|
return;
|
|
}
|
|
|
|
let {range} = this.editor;
|
|
let selection = window.getSelection();
|
|
let windowRange;
|
|
// Safari can throw an IndexSizeError from selection.getRangeAt(0)
|
|
if (selection.type !== 'None') {
|
|
windowRange = selection && selection.getRangeAt(0);
|
|
}
|
|
let element = range.head && range.head.section && range.head.section.renderNode && range.head.section.renderNode.element;
|
|
|
|
// prevent scroll jumps when a card is selected
|
|
if (!options.jumpToCard && range && range.head.section && range.head.section.isCardSection) {
|
|
return;
|
|
}
|
|
|
|
// start/endContainer matching editor element means the window range is
|
|
// outside of a text element so we don't want to scroll incorrectly
|
|
// (happens when replacing a selection with a link on paste)
|
|
if (windowRange &&
|
|
windowRange.startContainer === this.editor.element &&
|
|
windowRange.endContainer === this.editor.element
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (windowRange) {
|
|
// cursorTop is relative to the window rather than document or scroll container
|
|
let {top: cursorTop, height: cursorHeight} = windowRange.getBoundingClientRect();
|
|
let viewportHeight = window.innerHeight;
|
|
let offsetTop = 0;
|
|
let offsetBottom = 0;
|
|
let scrollTop = this._scrollContainer.scrollTop;
|
|
|
|
if (this.scrollOffsetTopSelector) {
|
|
let topElement = document.querySelector(this.scrollOffsetTopSelector);
|
|
offsetTop = topElement ? topElement.offsetHeight : 0;
|
|
}
|
|
|
|
if (this.scrollOffsetBottomSelector) {
|
|
let bottomElement = document.querySelector(this.scrollOffsetBottomSelector);
|
|
offsetBottom = bottomElement ? bottomElement.offsetHeight : 0;
|
|
}
|
|
|
|
// for empty paragraphs the window selection range will be 0,0,0,0
|
|
// so grab the element's bounding rect instead
|
|
if (cursorTop === 0 && cursorHeight === 0) {
|
|
if (!element) {
|
|
return;
|
|
}
|
|
|
|
({top: cursorTop, height: cursorHeight} = element.getBoundingClientRect());
|
|
}
|
|
|
|
// keep cursor in view at the top
|
|
if (cursorTop < 0 + offsetTop) {
|
|
this._scrollContainer.scrollTop = scrollTop - offsetTop + cursorTop - 20;
|
|
return;
|
|
}
|
|
|
|
let cursorBottom = cursorTop + cursorHeight;
|
|
let paddingBottom = 0;
|
|
let distanceFromViewportBottom = cursorBottom - viewportHeight;
|
|
let atBottom = false;
|
|
|
|
// if we're at the bottom of the doc we should keep the bottom
|
|
// padding in view, otherwise just scroll to keep the cursor in view
|
|
if (this._scrollContainer.scrollTop + this._scrollContainer.offsetHeight + 200 >= this._scrollContainer.scrollHeight) {
|
|
atBottom = true;
|
|
paddingBottom = parseFloat(getComputedStyle(this.element.parentNode).getPropertyValue('padding-bottom'));
|
|
}
|
|
|
|
if (cursorBottom > viewportHeight - offsetBottom - paddingBottom) {
|
|
if (atBottom) {
|
|
this._scrollContainer.scrollTop = this._scrollContainer.scrollHeight;
|
|
} else {
|
|
this._scrollContainer.scrollTop = scrollTop + offsetBottom + distanceFromViewportBottom + 20;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
_registerCardReorderDragDropHandler() {
|
|
let cardDragDropContainer = this.koenigDragDropHandler.registerContainer(this.editor.element, {
|
|
draggableSelector: ':scope > div', // cards
|
|
droppableSelector: ':scope > *', // all block elements
|
|
onDragStart: run.bind(this, this._onDragStart),
|
|
getDraggableInfo: run.bind(this, this._getDraggableInfo),
|
|
createGhostElement: run.bind(this, this._createCardDragElement),
|
|
getIndicatorPosition: run.bind(this, this._getDropIndicatorPosition),
|
|
onDrop: run.bind(this, this._onCardDrop),
|
|
onDropEnd: run.bind(this, this._onDropEnd)
|
|
});
|
|
|
|
this._cardDragDropContainer = cardDragDropContainer;
|
|
},
|
|
|
|
_onDragStart() {
|
|
this._cardDragDropContainer.refresh();
|
|
},
|
|
|
|
_getDraggableInfo(draggableElement) {
|
|
let card = this.getCardFromElement(draggableElement);
|
|
|
|
if (!card) {
|
|
return false;
|
|
}
|
|
|
|
// TODO: payload should probably contain everything here as well as the
|
|
// card payload so that draggableInfo has a consistent shape
|
|
return {
|
|
type: 'card',
|
|
cardName: card.cardName,
|
|
payload: card.payload,
|
|
destinationElementId: card.destinationElementId
|
|
};
|
|
},
|
|
|
|
_createCardDragElement(draggableInfo) {
|
|
let {cardName} = draggableInfo;
|
|
|
|
if (!cardName || cardName === 'image') {
|
|
return;
|
|
}
|
|
|
|
let ghostElement = document.createElement('div');
|
|
ghostElement.classList.add('absolute', 'flex', 'flex-column', 'justify-center',
|
|
'items-center', 'w15', 'h15', 'br3', 'bg-white', 'shadow-1');
|
|
ghostElement.style.top = '0';
|
|
ghostElement.style.left = '-100%';
|
|
ghostElement.style.zIndex = 10001;
|
|
ghostElement.style.willChange = 'transform';
|
|
|
|
let iconElement = document.createElement('div');
|
|
iconElement.classList.add('flex', 'items-center');
|
|
|
|
let svgIconHtml = svgJar(CARD_ICON_MAP[cardName], {class: 'w8 h8'});
|
|
iconElement.insertAdjacentHTML('beforeend', svgIconHtml.string);
|
|
|
|
ghostElement.appendChild(iconElement);
|
|
return ghostElement;
|
|
},
|
|
|
|
_getDropIndicatorPosition(draggableInfo, droppableElem, position) {
|
|
let droppables = Array.from(this.editor.element.querySelectorAll(':scope > *'));
|
|
let droppableIndex = droppables.indexOf(droppableElem);
|
|
let draggableIndex = droppables.indexOf(draggableInfo.element);
|
|
|
|
// allow card and image drops (images can be dragged out of a gallery)
|
|
if (draggableInfo.type !== 'card' && draggableInfo.type !== 'image') {
|
|
return false;
|
|
}
|
|
|
|
if (this._isCardDropAllowed(draggableIndex, droppableIndex, position)) {
|
|
let insertIndex = droppableIndex;
|
|
if (position.match(/bottom/)) {
|
|
insertIndex += 1;
|
|
}
|
|
|
|
let beforeElems, afterElems;
|
|
if (position.match(/bottom/)) {
|
|
beforeElems = droppables.slice(0, droppableIndex + 1);
|
|
afterElems = droppables.slice(droppableIndex + 1);
|
|
} else {
|
|
beforeElems = droppables.slice(0, droppableIndex);
|
|
afterElems = droppables.slice(droppableIndex);
|
|
}
|
|
|
|
return {
|
|
direction: 'vertical',
|
|
position: position.match(/top/) ? 'top' : 'bottom',
|
|
beforeElems,
|
|
afterElems,
|
|
insertIndex: insertIndex
|
|
};
|
|
}
|
|
|
|
return false;
|
|
},
|
|
|
|
_onCardDrop(draggableInfo) {
|
|
if (draggableInfo.type !== 'card' && draggableInfo.type !== 'image') {
|
|
return false;
|
|
}
|
|
|
|
let droppables = Array.from(this.editor.element.querySelectorAll(':scope > *'));
|
|
let draggableIndex = droppables.indexOf(draggableInfo.element);
|
|
|
|
if (this._isCardDropAllowed(draggableIndex, draggableInfo.insertIndex)) {
|
|
if (draggableInfo.type === 'card') {
|
|
let card = this.getCardFromElement(draggableInfo.element);
|
|
let cardSection = this.getSectionFromCard(card);
|
|
let difference = draggableIndex - draggableInfo.insertIndex;
|
|
|
|
if (draggableIndex < draggableInfo.insertIndex) {
|
|
difference += 1;
|
|
}
|
|
|
|
if (difference !== 0) {
|
|
this.editor.run((postEditor) => {
|
|
do {
|
|
if (difference > 0) {
|
|
cardSection = postEditor.moveSectionUp(cardSection);
|
|
difference -= 1;
|
|
} else if (difference < 0) {
|
|
cardSection = postEditor.moveSectionDown(cardSection);
|
|
difference += 1;
|
|
}
|
|
} while (difference !== 0);
|
|
});
|
|
}
|
|
|
|
// make sure we don't remove the dropped card in the card->card drop handler
|
|
this._skipOnDropEnd = true;
|
|
|
|
return true;
|
|
}
|
|
|
|
if (draggableInfo.type === 'image') {
|
|
// we need to create an image card from a raw image payload
|
|
this.editor.run((postEditor) => {
|
|
let imageCard = postEditor.builder.createCardSection('image', draggableInfo.payload);
|
|
let sections = this.editor.post.sections;
|
|
let droppableSection = sections.objectAt(draggableInfo.insertIndex);
|
|
postEditor.insertSectionBefore(sections, imageCard, droppableSection);
|
|
postEditor.setRange(imageCard.tailPosition());
|
|
});
|
|
|
|
return true;
|
|
}
|
|
}
|
|
},
|
|
|
|
// TODO: more or less duplicated in koenig-card-gallery other than direction
|
|
// - move to DnD container?
|
|
_isCardDropAllowed(draggableIndex, droppableIndex, position = '') {
|
|
// images can be dragged out of a gallery to any position
|
|
if (draggableIndex === -1) {
|
|
return true;
|
|
}
|
|
|
|
// can't drop on itself or when droppableIndex doesn't exist
|
|
if (draggableIndex === droppableIndex || typeof droppableIndex === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
// account for dropping at beginning or end of a row
|
|
if (position.match(/top/)) {
|
|
droppableIndex -= 1;
|
|
}
|
|
|
|
if (position.match(/bottom/)) {
|
|
droppableIndex += 1;
|
|
}
|
|
|
|
return droppableIndex !== draggableIndex;
|
|
},
|
|
|
|
// a card can be dropped into another card which means we need to remove the original
|
|
_onDropEnd(draggableInfo, success) {
|
|
if (this._skipOnDropEnd || !success || draggableInfo.type !== 'card') {
|
|
this._skipOnDropEnd = false;
|
|
return;
|
|
}
|
|
|
|
let card = this.getCardFromElement(draggableInfo.element);
|
|
this.deleteCard(card, NO_CURSOR_MOVEMENT);
|
|
},
|
|
|
|
// calculate the number of words in rich-text sections and query cards for
|
|
// their own word and image counts. Image counts are used for reading-time
|
|
_calculateWordCount() {
|
|
run.throttle(this, this._throttledWordCount, 100, false);
|
|
},
|
|
|
|
_throttledWordCount() {
|
|
if (this.isDestroying || this.isDestroyed) {
|
|
return;
|
|
}
|
|
|
|
let wordCount = 0;
|
|
let imageCount = 0;
|
|
|
|
this.editor.post.walkAllLeafSections((section) => {
|
|
if (section.isCardSection) {
|
|
// get counts from card components
|
|
let card = this.getCardFromSection(section);
|
|
let cardCounts = get(card, 'component.counts') || {};
|
|
wordCount += cardCounts.wordCount || 0;
|
|
imageCount += cardCounts.imageCount || 0;
|
|
} else {
|
|
wordCount += countWords(section.text);
|
|
}
|
|
});
|
|
|
|
if (wordCount !== this.wordCount || imageCount !== this.imageCount) {
|
|
let readingTime = calculateReadingTime({wordCount, imageCount});
|
|
|
|
this.setProperties({
|
|
wordCount,
|
|
imageCount,
|
|
readingTime
|
|
});
|
|
|
|
this.wordCountDidChange({wordCount, imageCount, readingTime});
|
|
}
|
|
},
|
|
|
|
_triggerTextHandlers() {
|
|
let {editor} = this;
|
|
|
|
// don't trigger our text input handlers for pastes within cards or
|
|
// outside of the editor canvas
|
|
if (!editor.cursor.isAddressable(event.target)) {
|
|
return;
|
|
}
|
|
|
|
// must be run after the normal events have finished so that the
|
|
// backtick char exists in the editor
|
|
run.next(this, function () {
|
|
let matchedHandler = editor._eventManager._textInputHandler._findHandler();
|
|
if (matchedHandler) {
|
|
let [handler, matches] = matchedHandler;
|
|
handler.run(editor, matches);
|
|
}
|
|
});
|
|
},
|
|
|
|
// store a reference to the editor for the acceptance test helpers
|
|
_setExpandoProperty(editor) {
|
|
let config = getOwner(this).resolveRegistration('config:environment');
|
|
if (this.element && config.environment === 'test') {
|
|
this.element[TESTING_EXPANDO_PROPERTY] = editor;
|
|
}
|
|
}
|
|
});
|