2018-01-30 13:01:07 +03:00
|
|
|
/*
|
|
|
|
* Based on ember-mobiledoc-editor
|
|
|
|
* https://github.com/bustle/ember-mobiledoc-editor
|
|
|
|
*/
|
|
|
|
|
|
|
|
import Component from '@ember/component';
|
|
|
|
import Editor from 'mobiledoc-kit/editor/editor';
|
|
|
|
import Ember from 'ember';
|
2018-01-30 18:18:08 +03:00
|
|
|
import EmberObject from '@ember/object';
|
2018-01-30 13:58:28 +03:00
|
|
|
import defaultAtoms from '../options/atoms';
|
2018-01-30 18:18:08 +03:00
|
|
|
import defaultCards from '../options/cards';
|
2018-01-30 13:01:07 +03:00
|
|
|
import layout from '../templates/components/koenig-editor';
|
2018-01-30 13:58:28 +03:00
|
|
|
import registerKeyCommands from '../options/key-commands';
|
2018-01-30 13:01:07 +03:00
|
|
|
import registerTextExpansions from '../options/text-expansions';
|
2018-01-30 18:18:08 +03:00
|
|
|
import {A} from '@ember/array';
|
2018-01-30 13:01:07 +03:00
|
|
|
import {MOBILEDOC_VERSION} from 'mobiledoc-kit/renderers/mobiledoc';
|
|
|
|
import {assign} from '@ember/polyfills';
|
|
|
|
import {camelize, capitalize} from '@ember/string';
|
|
|
|
import {computed} from '@ember/object';
|
2018-01-30 18:18:08 +03:00
|
|
|
import {copy} from '@ember/object/internals';
|
2018-01-30 13:01:07 +03:00
|
|
|
import {run} from '@ember/runloop';
|
|
|
|
|
2018-01-30 18:18:08 +03:00
|
|
|
export const ADD_CARD_HOOK = 'addComponent';
|
|
|
|
export const REMOVE_CARD_HOOK = 'removeComponent';
|
|
|
|
|
2018-01-30 13:01:07 +03:00
|
|
|
// 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 BLANK_DOC = {
|
|
|
|
version: MOBILEDOC_VERSION,
|
|
|
|
markups: [],
|
|
|
|
atoms: [],
|
|
|
|
cards: [],
|
|
|
|
sections: [
|
|
|
|
[1, 'p', [
|
|
|
|
[0, [], 0, '']
|
|
|
|
]]
|
|
|
|
]
|
|
|
|
};
|
|
|
|
|
2018-02-01 14:26:24 +03:00
|
|
|
// map card names to component names
|
|
|
|
export const CARD_COMPONENT_MAP = {
|
|
|
|
hr: 'koenig-card-hr',
|
|
|
|
image: 'koenig-card-image',
|
|
|
|
markdown: 'koenig-card-markdown',
|
2018-02-03 21:20:50 +03:00
|
|
|
'card-markdown': 'koenig-card-markdown', // backwards-compat with markdown editor
|
|
|
|
html: 'koenig-card-html'
|
2018-02-01 14:26:24 +03:00
|
|
|
};
|
|
|
|
|
2018-02-04 22:35:44 +03:00
|
|
|
const CURSOR_BEFORE = -1;
|
|
|
|
const CURSOR_AFTER = 1;
|
2018-03-15 20:54:15 +03:00
|
|
|
const NO_CURSOR_MOVEMENT = 0;
|
2018-02-04 22:35:44 +03:00
|
|
|
|
2018-01-30 13:01:07 +03:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
|
|
|
export default Component.extend({
|
|
|
|
layout,
|
|
|
|
|
|
|
|
tagName: 'article',
|
|
|
|
classNames: ['koenig-editor'],
|
|
|
|
|
|
|
|
// public attrs
|
|
|
|
mobiledoc: null,
|
|
|
|
placeholder: 'Write here...',
|
|
|
|
autofocus: false,
|
|
|
|
spellcheck: true,
|
|
|
|
options: null,
|
|
|
|
scrollContainer: '',
|
|
|
|
|
|
|
|
// internal properties
|
|
|
|
editor: null,
|
|
|
|
activeMarkupTagNames: null,
|
|
|
|
activeSectionTagNames: null,
|
|
|
|
selectedRange: null,
|
2018-01-30 18:18:08 +03:00
|
|
|
componentCards: null,
|
2018-01-30 13:01:07 +03:00
|
|
|
|
|
|
|
// private properties
|
|
|
|
_localMobiledoc: null,
|
|
|
|
_upstreamMobiledoc: null,
|
|
|
|
_startedRunLoop: false,
|
|
|
|
_lastIsEditingDisabled: false,
|
|
|
|
_isRenderingEditor: false,
|
2018-02-04 22:35:44 +03:00
|
|
|
_selectedCard: null,
|
2018-01-30 13:01:07 +03:00
|
|
|
|
|
|
|
// closure actions
|
|
|
|
willCreateEditor() {},
|
|
|
|
didCreateEditor() {},
|
|
|
|
onChange() {},
|
2018-02-04 22:35:44 +03:00
|
|
|
cursorDidExitAtTop() {},
|
2018-01-30 13:01:07 +03:00
|
|
|
|
|
|
|
/* computed properties -------------------------------------------------- */
|
|
|
|
|
|
|
|
// merge in named options with the `options` property data-bag
|
2018-01-30 18:18:08 +03:00
|
|
|
// TODO: what is the `options` property data-bag and when/where does it get set?
|
2018-01-30 13:01:07 +03:00
|
|
|
editorOptions: computed(function () {
|
|
|
|
let options = this.get('options') || {};
|
2018-01-30 13:58:28 +03:00
|
|
|
let atoms = this.get('atoms') || [];
|
2018-01-30 18:18:08 +03:00
|
|
|
let cards = this.get('cards') || [];
|
2018-01-30 13:58:28 +03:00
|
|
|
|
2018-01-30 18:18:08 +03:00
|
|
|
// 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
|
2018-01-30 13:58:28 +03:00
|
|
|
atoms = Array.concat(defaultAtoms, atoms);
|
2018-01-30 18:18:08 +03:00
|
|
|
cards = Array.concat(defaultCards, cards);
|
2018-01-30 13:01:07 +03:00
|
|
|
|
|
|
|
return assign({
|
|
|
|
placeholder: this.get('placeholder'),
|
|
|
|
spellcheck: this.get('spellcheck'),
|
2018-01-30 13:58:28 +03:00
|
|
|
autofocus: this.get('autofocus'),
|
2018-01-30 18:18:08 +03:00
|
|
|
atoms,
|
|
|
|
cards
|
2018-01-30 13:01:07 +03:00
|
|
|
}, options);
|
|
|
|
}),
|
|
|
|
|
|
|
|
/* lifecycle hooks ------------------------------------------------------ */
|
|
|
|
|
|
|
|
init() {
|
|
|
|
this._super(...arguments);
|
|
|
|
|
|
|
|
// set a blank mobiledoc if we didn't receive anything
|
|
|
|
let mobiledoc = this.get('mobiledoc');
|
|
|
|
if (!mobiledoc) {
|
|
|
|
mobiledoc = BLANK_DOC;
|
|
|
|
this.set('mobiledoc', mobiledoc);
|
|
|
|
}
|
|
|
|
|
2018-01-30 18:18:08 +03:00
|
|
|
this.set('componentCards', A([]));
|
|
|
|
this.set('activeMarkupTagNames', {});
|
|
|
|
this.set('activeSectionTagNames', {});
|
|
|
|
|
2018-01-30 13:01:07 +03:00
|
|
|
this._startedRunLoop = false;
|
|
|
|
},
|
|
|
|
|
|
|
|
willRender() {
|
|
|
|
// use a default mobiledoc. If there are no changes then return early
|
|
|
|
let mobiledoc = this.get('mobiledoc') || BLANK_DOC;
|
|
|
|
let mobiledocIsSame =
|
|
|
|
(this._localMobiledoc && this._localMobiledoc === mobiledoc) ||
|
|
|
|
(this._upstreamMobiledoc && this._upstreamMobiledoc === mobiledoc);
|
|
|
|
let isEditingDisabledIsSame =
|
|
|
|
this._lastIsEditingDisabled === this.get('isEditingDisabled');
|
|
|
|
|
|
|
|
// no change to mobiledoc, no need to recreate the editor
|
|
|
|
if (mobiledocIsSame && isEditingDisabledIsSame) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// update our internal references
|
|
|
|
this._lastIsEditingDisabled = this.get('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.get('editor');
|
|
|
|
if (editor) {
|
|
|
|
editor.destroy();
|
|
|
|
}
|
|
|
|
|
|
|
|
// create a new editor
|
|
|
|
let editorOptions = this.get('editorOptions');
|
|
|
|
editorOptions.mobiledoc = mobiledoc;
|
2018-01-30 18:18:08 +03:00
|
|
|
|
|
|
|
let componentHooks = {
|
|
|
|
// triggered when a card section is added to the mobiledoc
|
|
|
|
[ADD_CARD_HOOK]: ({env, options, payload}) => {
|
|
|
|
let cardId = Ember.uuid();
|
|
|
|
let cardName = env.name;
|
2018-02-01 14:26:24 +03:00
|
|
|
let componentName = CARD_COMPONENT_MAP[cardName];
|
2018-01-30 18:18:08 +03:00
|
|
|
|
|
|
|
// 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 destinationElementId = `koenig-editor-card-${cardId}`;
|
|
|
|
let destinationElement = document.createElement('div');
|
|
|
|
destinationElement.id = destinationElementId;
|
|
|
|
|
|
|
|
// the payload must be copied to avoid sharing the reference
|
|
|
|
payload = copy(payload, true);
|
|
|
|
|
2018-02-01 14:26:24 +03:00
|
|
|
// all of the properties that will be passed through to the
|
2018-01-30 18:18:08 +03:00
|
|
|
// component cards via the template
|
|
|
|
let card = EmberObject.create({
|
|
|
|
destinationElement,
|
|
|
|
destinationElementId,
|
|
|
|
cardName,
|
2018-02-01 14:26:24 +03:00
|
|
|
componentName,
|
2018-01-30 18:18:08 +03:00
|
|
|
payload,
|
|
|
|
env,
|
|
|
|
options,
|
|
|
|
editor,
|
2018-02-13 21:00:54 +03:00
|
|
|
postModel: env.postModel,
|
|
|
|
isSelected: false,
|
|
|
|
isEditing: false
|
2018-01-30 18:18:08 +03:00
|
|
|
});
|
|
|
|
|
|
|
|
// after render we render the full ember card via {{-in-element}}
|
|
|
|
run.schedule('afterRender', () => {
|
|
|
|
this.get('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.get('componentCards').removeObject(card);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
editorOptions.cardOptions = componentHooks;
|
|
|
|
|
2018-01-30 13:01:07 +03:00
|
|
|
editor = new Editor(editorOptions);
|
|
|
|
|
|
|
|
// set up key commands and text expansions (MD conversion)
|
2018-01-30 13:58:28 +03:00
|
|
|
// TODO: this will override any passed in options, we should allow the
|
|
|
|
// default behaviour to be overridden by addon consumers
|
|
|
|
registerKeyCommands(editor);
|
2018-01-30 13:01:07 +03:00
|
|
|
registerTextExpansions(editor);
|
|
|
|
|
2018-02-04 22:35:44 +03:00
|
|
|
// the cursor is always positioned after a selected card so DELETE wont
|
|
|
|
// work to remove the card like BACKSPACE does. Add a custom command to
|
|
|
|
// override the default behaviour when a card is selected
|
|
|
|
editor.registerKeyCommand({
|
|
|
|
str: 'DEL',
|
|
|
|
run: run.bind(this, this.handleDelKey)
|
|
|
|
}),
|
|
|
|
|
|
|
|
// by default mobiledoc-kit will remove the selected card but replace it
|
|
|
|
// with a blank paragraph, we want the cursor to go to the previous
|
|
|
|
// section instead
|
|
|
|
editor.registerKeyCommand({
|
|
|
|
str: 'BACKSPACE',
|
|
|
|
run: run.bind(this, this.handleBackspaceKey)
|
|
|
|
}),
|
|
|
|
|
|
|
|
editor.registerKeyCommand({
|
|
|
|
str: 'UP',
|
|
|
|
run: run.bind(this, this.handleUpKey)
|
|
|
|
});
|
|
|
|
|
|
|
|
editor.registerKeyCommand({
|
|
|
|
str: 'LEFT',
|
|
|
|
run: run.bind(this, this.handleLeftKey)
|
|
|
|
});
|
|
|
|
|
2018-02-13 21:00:54 +03:00
|
|
|
editor.registerKeyCommand({
|
|
|
|
str: 'META+ENTER',
|
|
|
|
run: run.bind(this, this.handleCmdEnter)
|
|
|
|
});
|
|
|
|
|
2018-01-30 13:01:07 +03:00
|
|
|
// 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();
|
|
|
|
}
|
|
|
|
});
|
|
|
|
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);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
if (this.get('isEditingDisabled')) {
|
|
|
|
editor.disableEditing();
|
|
|
|
}
|
|
|
|
|
|
|
|
this.set('editor', editor);
|
|
|
|
this.didCreateEditor(editor);
|
|
|
|
},
|
|
|
|
|
|
|
|
// our ember component has rendered, now we need to render the mobiledoc
|
|
|
|
// editor itself if necessary
|
|
|
|
didRender() {
|
|
|
|
this._super(...arguments);
|
|
|
|
let editor = this.get('editor');
|
|
|
|
if (!editor.hasRendered) {
|
|
|
|
let editorElement = this.element.querySelector('.koenig-editor__editor');
|
|
|
|
this._isRenderingEditor = true;
|
|
|
|
editor.render(editorElement);
|
|
|
|
this._isRenderingEditor = false;
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
willDestroyElement() {
|
|
|
|
let editor = this.get('editor');
|
|
|
|
editor.destroy();
|
|
|
|
this._super(...arguments);
|
|
|
|
},
|
|
|
|
|
|
|
|
actions: {
|
|
|
|
toggleMarkup(markupTagName) {
|
|
|
|
let editor = this.get('editor');
|
|
|
|
editor.toggleMarkup(markupTagName);
|
|
|
|
},
|
|
|
|
|
|
|
|
toggleSection(sectionTagName) {
|
|
|
|
let editor = this.get('editor');
|
|
|
|
editor.toggleSection(sectionTagName);
|
2018-02-02 18:22:37 +03:00
|
|
|
},
|
|
|
|
|
|
|
|
replaceWithCardSection(cardName, range) {
|
|
|
|
let editor = this.get('editor');
|
|
|
|
let {head: {section}} = range;
|
|
|
|
|
|
|
|
editor.run((postEditor) => {
|
|
|
|
let {builder} = postEditor;
|
|
|
|
let card = builder.createCardSection(cardName);
|
|
|
|
let needsTrailingParagraph = !section.next;
|
|
|
|
|
|
|
|
postEditor.replaceSection(section, card);
|
|
|
|
|
|
|
|
if (needsTrailingParagraph) {
|
|
|
|
let newSection = postEditor.builder.createMarkupSection('p');
|
|
|
|
postEditor.insertSectionAtEnd(newSection);
|
|
|
|
postEditor.setRange(newSection.tailPosition());
|
|
|
|
}
|
|
|
|
});
|
2018-02-13 21:00:54 +03:00
|
|
|
|
|
|
|
// 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
|
|
|
|
run.schedule('afterRender', this, function () {
|
|
|
|
let card = this.get('componentCards.lastObject');
|
|
|
|
this.editCard(card);
|
|
|
|
});
|
2018-02-02 18:22:37 +03:00
|
|
|
},
|
|
|
|
|
2018-02-04 22:35:44 +03:00
|
|
|
selectCard(card) {
|
2018-02-13 21:00:54 +03:00
|
|
|
this.selectCard(card);
|
|
|
|
},
|
2018-02-04 22:35:44 +03:00
|
|
|
|
2018-02-13 21:00:54 +03:00
|
|
|
editCard(card) {
|
|
|
|
this.editCard(card);
|
2018-02-04 22:35:44 +03:00
|
|
|
},
|
|
|
|
|
|
|
|
deselectCard(card) {
|
2018-02-13 21:00:54 +03:00
|
|
|
this.deselectCard(card);
|
2018-02-22 23:41:40 +03:00
|
|
|
},
|
|
|
|
|
2018-03-15 20:54:15 +03:00
|
|
|
deleteCard(card, cursorMovement = NO_CURSOR_MOVEMENT) {
|
|
|
|
this._deleteCard(card, cursorMovement);
|
|
|
|
},
|
|
|
|
|
2018-02-22 23:41:40 +03:00
|
|
|
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.get('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());
|
|
|
|
});
|
2018-01-30 13:01:07 +03:00
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
/* public methods ------------------------------------------------------- */
|
|
|
|
|
|
|
|
postDidChange(editor) {
|
|
|
|
let serializeVersion = this.get('serializeVersion');
|
|
|
|
let updatedMobiledoc = editor.serialize(serializeVersion);
|
|
|
|
this._localMobiledoc = updatedMobiledoc;
|
|
|
|
|
|
|
|
// trigger closure action
|
|
|
|
this.onChange(updatedMobiledoc);
|
|
|
|
},
|
|
|
|
|
|
|
|
cursorDidChange(editor) {
|
2018-02-15 18:52:08 +03:00
|
|
|
let {head, isCollapsed, head: {section}} = editor.range;
|
|
|
|
|
2018-02-13 21:00:54 +03:00
|
|
|
// 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;
|
2018-02-15 18:02:37 +03:00
|
|
|
this.set('selectedRange', editor.range);
|
2018-02-13 21:00:54 +03:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2018-02-22 01:46:27 +03:00
|
|
|
// 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) {
|
2018-02-04 22:35:44 +03:00
|
|
|
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) {
|
2018-03-14 15:13:54 +03:00
|
|
|
// 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);
|
|
|
|
});
|
2018-02-04 22:35:44 +03:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// deselect any selected card because the cursor is no longer on a card
|
|
|
|
if (this._selectedCard) {
|
2018-02-13 21:00:54 +03:00
|
|
|
this.deselectCard(this._selectedCard);
|
2018-02-04 22:35:44 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
// pass the selected range through to the toolbar + menu components
|
2018-01-30 13:01:07 +03:00
|
|
|
this.set('selectedRange', editor.range);
|
|
|
|
},
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
2018-02-04 22:35:44 +03:00
|
|
|
handleBackspaceKey() {
|
|
|
|
let {isCollapsed, head: {offset, section}} = this.editor.range;
|
|
|
|
|
|
|
|
// if a card is selected we should delete the card then place the cursor
|
|
|
|
// at the end of the previous section
|
|
|
|
if (this._selectedCard) {
|
|
|
|
let cursorPosition = section.prev ? CURSOR_BEFORE : CURSOR_AFTER;
|
|
|
|
this._deleteCard(this._selectedCard, cursorPosition);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2018-03-14 19:13:33 +03:00
|
|
|
// if the caret is at the beginning of the doc, on a blank para, and
|
|
|
|
// there are more sections then delete the para and trigger the
|
|
|
|
// `cursorDidExitAtTop` closure action
|
|
|
|
let isFirstSection = section === section.parent.sections.head;
|
|
|
|
if (isFirstSection && isCollapsed && offset === 0 && (section.isBlank || section.text === '') && section.next) {
|
|
|
|
this.editor.run((postEditor) => {
|
|
|
|
postEditor.removeSection(section);
|
|
|
|
});
|
|
|
|
|
|
|
|
// allow default behaviour which will trigger `cursorDidChange` and
|
|
|
|
// fire our `cursorDidExitAtTop` action
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2018-02-04 22:35:44 +03:00
|
|
|
// if the section about to be deleted by a backspace is a card then
|
2018-02-15 19:31:51 +03:00
|
|
|
// actually delete the card rather than selecting it.
|
|
|
|
// However, if the current paragraph is blank then delete the paragraph
|
|
|
|
// instead - allows blank paragraphs between cards to be deleted and
|
|
|
|
// feels more natural
|
|
|
|
if (isCollapsed && offset === 0 && section.prev && section.prev.type === 'card-section' && !section.isBlank) {
|
2018-02-04 22:35:44 +03:00
|
|
|
let card = this._getCardFromSection(section.prev);
|
2018-02-13 21:00:54 +03:00
|
|
|
this._deleteCard(card, CURSOR_AFTER);
|
2018-02-04 22:35:44 +03:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
return false;
|
|
|
|
},
|
|
|
|
|
|
|
|
handleDelKey() {
|
|
|
|
let {isCollapsed, head: {offset, section}} = this.editor.range;
|
|
|
|
|
|
|
|
// if a card is selected we should delete the card then place the cursor
|
|
|
|
// at the beginning of the next section or select the following card
|
|
|
|
if (this._selectedCard) {
|
|
|
|
let selectNextCard = section.next.type === 'card-section';
|
|
|
|
let nextCard = this._getCardFromSection(section.next);
|
|
|
|
|
|
|
|
this._deleteCard(this._selectedCard, CURSOR_AFTER);
|
|
|
|
|
|
|
|
if (selectNextCard) {
|
2018-02-13 21:00:54 +03:00
|
|
|
this.selectCard(nextCard);
|
2018-02-04 22:35:44 +03:00
|
|
|
}
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// if the section about to be deleted by a DEL is a card then actually
|
2018-03-14 14:01:28 +03:00
|
|
|
// delete the card rather than selecting it
|
|
|
|
// However, if the current paragraph is blank then delete the paragraph
|
|
|
|
// instead - allows blank paragraphs between cards to be deleted and
|
|
|
|
// feels more natural
|
|
|
|
if (isCollapsed && offset === section.length && section.next && section.next.type === 'card-section' && !section.isBlank) {
|
2018-02-04 22:35:44 +03:00
|
|
|
let card = this._getCardFromSection(section.next);
|
|
|
|
this._deleteCard(card, CURSOR_BEFORE);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
return false;
|
|
|
|
},
|
|
|
|
|
2018-02-20 18:34:09 +03:00
|
|
|
// trigger a closure action to indicate that the caret "left" the top of
|
|
|
|
// the editor canvas when pressing UP with the caret at the beginning of
|
|
|
|
// the doc
|
2018-02-04 22:35:44 +03:00
|
|
|
handleUpKey(editor) {
|
|
|
|
let {isCollapsed, head: {offset, section}} = editor.range;
|
|
|
|
|
|
|
|
if (isCollapsed && !section.prev && offset === 0) {
|
|
|
|
this.cursorDidExitAtTop();
|
|
|
|
}
|
|
|
|
|
|
|
|
return false;
|
|
|
|
},
|
|
|
|
|
|
|
|
handleLeftKey(editor) {
|
|
|
|
let {isCollapsed, head: {offset, section}} = editor.range;
|
|
|
|
|
2018-02-22 01:46:27 +03:00
|
|
|
// trigger a closure action to indicate that the caret "left" the top of
|
|
|
|
// the editor canvas if the caret is at the very beginning of the doc
|
2018-02-04 22:35:44 +03:00
|
|
|
if (isCollapsed && !section.prev && offset === 0) {
|
|
|
|
this.cursorDidExitAtTop();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2018-02-22 01:46:27 +03:00
|
|
|
// if we have a selected card move the caret to end of the previous
|
|
|
|
// section because the cursor will likely be at the end of the card
|
|
|
|
// section meaning the default behaviour would move the cursor to the
|
|
|
|
// beginning and require two key presses instead of one
|
|
|
|
if (this._selectedCard && this._selectedCard.postModel === section) {
|
|
|
|
this._moveCaretToTailOfSection(section.prev, false);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2018-02-04 22:35:44 +03:00
|
|
|
return false;
|
|
|
|
},
|
|
|
|
|
2018-02-20 18:34:09 +03:00
|
|
|
// CMD+ENTER is our keyboard shortcut for putting a selected card into
|
|
|
|
// edit mode
|
2018-02-13 21:00:54 +03:00
|
|
|
handleCmdEnter() {
|
|
|
|
if (this._selectedCard) {
|
|
|
|
this.editCard(this._selectedCard);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
return false;
|
|
|
|
},
|
|
|
|
|
|
|
|
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);
|
2018-02-22 01:46:27 +03:00
|
|
|
this._moveCaretToTailOfSection(section);
|
2018-02-13 21:00:54 +03:00
|
|
|
},
|
|
|
|
|
|
|
|
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);
|
|
|
|
},
|
|
|
|
|
|
|
|
deselectCard(card) {
|
|
|
|
card.set('isEditing', false);
|
|
|
|
card.set('isSelected', false);
|
|
|
|
this._selectedCard = null;
|
|
|
|
this._showCursor();
|
|
|
|
},
|
|
|
|
|
2018-01-30 13:01:07 +03:00
|
|
|
/* internal methods ----------------------------------------------------- */
|
|
|
|
|
2018-02-04 22:35:44 +03:00
|
|
|
_getCardFromSection(section) {
|
|
|
|
if (!section || section.type !== 'card-section') {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
let cardId = section.renderNode.element.querySelector('.__mobiledoc-card').firstChild.id;
|
|
|
|
let cards = this.get('componentCards');
|
|
|
|
|
|
|
|
return cards.findBy('destinationElementId', cardId);
|
|
|
|
},
|
|
|
|
|
|
|
|
_getSectionFromCard(card) {
|
|
|
|
return card.env.postModel;
|
|
|
|
},
|
|
|
|
|
2018-02-22 23:41:40 +03:00
|
|
|
_moveCaretToHeadOfSection(section, skipCursorChange = true) {
|
|
|
|
this._moveCaretToSection('head', section, skipCursorChange);
|
|
|
|
},
|
|
|
|
|
2018-02-22 01:46:27 +03:00
|
|
|
_moveCaretToTailOfSection(section, skipCursorChange = true) {
|
2018-02-22 23:41:40 +03:00
|
|
|
this._moveCaretToSection('tail', section, skipCursorChange);
|
|
|
|
},
|
|
|
|
|
|
|
|
_moveCaretToSection(position, section, skipCursorChange = true) {
|
2018-02-22 01:46:27 +03:00
|
|
|
this.editor.run((postEditor) => {
|
2018-02-22 23:41:40 +03:00
|
|
|
let sectionPosition = position === 'head' ? section.headPosition() : section.tailPosition();
|
|
|
|
let range = sectionPosition.toRange();
|
2018-02-22 01:46:27 +03:00
|
|
|
|
|
|
|
// don't trigger another cursor change selection after selecting
|
|
|
|
if (skipCursorChange && !range.isEqual(this.editor.range)) {
|
|
|
|
this._skipCursorChange = true;
|
|
|
|
}
|
|
|
|
|
|
|
|
postEditor.setRange(range);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
2018-02-04 22:35:44 +03:00
|
|
|
_deleteCard(card, cursorDirection) {
|
|
|
|
this.editor.run((postEditor) => {
|
|
|
|
let section = card.env.postModel;
|
2018-03-14 19:30:28 +03:00
|
|
|
let nextPosition;
|
2018-02-04 22:35:44 +03:00
|
|
|
|
|
|
|
if (cursorDirection === CURSOR_BEFORE) {
|
2018-03-14 19:30:28 +03:00
|
|
|
nextPosition = section.prev.tailPosition();
|
2018-02-04 22:35:44 +03:00
|
|
|
} else {
|
2018-03-14 19:30:28 +03:00
|
|
|
nextPosition = section.next.headPosition();
|
2018-02-04 22:35:44 +03:00
|
|
|
}
|
|
|
|
|
2018-03-14 19:30:28 +03:00
|
|
|
postEditor.removeSection(section);
|
2018-03-15 20:54:15 +03:00
|
|
|
|
|
|
|
if (cursorDirection !== NO_CURSOR_MOVEMENT) {
|
|
|
|
postEditor.setRange(nextPosition);
|
|
|
|
}
|
2018-02-04 22:35:44 +03:00
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
_hideCursor() {
|
|
|
|
this.editor.element.style.caretColor = 'transparent';
|
|
|
|
},
|
|
|
|
|
|
|
|
_showCursor() {
|
|
|
|
this.editor.element.style.caretColor = 'auto';
|
|
|
|
},
|
|
|
|
|
2018-01-30 13:01:07 +03:00
|
|
|
// store a reference to the editor for the acceptance test helpers
|
|
|
|
_setExpandoProperty(editor) {
|
|
|
|
if (this.element && Ember.testing) {
|
|
|
|
this.element[TESTING_EXPANDO_PROPERTY] = editor;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|