/* * 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 {run} from '@ember/runloop'; import {inject as service} from '@ember/service'; import {svgJar} from 'ghost-admin/helpers/svg-jar'; import {task, waitForProperty} from 'ember-concurrency'; 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 getBlankMobileDoc(defaultTag = 'p') { return { ...BLANK_DOC, sections: [ [1, defaultTag, [ [0, [], 0, ''] ]] ] }; } 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; }), allComponentCardsRegistered: computed('componentCards.@each.component', function () { return this.componentCards.every(card => typeof card.component === 'object'); }), /* 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._cleanupTask.perform); } }); 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 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; }, cleanup() { this._cleanupScheduled = true; }, /* 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 `` or `` if (html) { // prevent mobiledoc's default paste event handler firing event.preventDefault(); event.stopImmediatePropagation(); let normalizedHtml = html .replace(/)/gi, '/gi, '') .replace(/)/gi, '/gi, ''); // 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) { let section = card.env.postModel; if (!section.parent) { // card has already been deleted, skip return; } this.editor.run((postEditor) => { 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 ----------------------------------------------------- */ _cleanupTask: task(function* () { yield waitForProperty(this, 'allComponentCardsRegistered'); this.componentCards.forEach((card) => { if (typeof card.component.isEmpty === 'boolean' && card.component.isEmpty) { this.deleteCard(card, NO_CURSOR_MOVEMENT); } }); this._cleanupScheduled = false; }), // 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; } } });