This repository has been archived by the owner on Nov 28, 2022. It is now read-only.
/
koenig-editor.js
837 lines (705 loc) · 29.4 KB
/
koenig-editor.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
/*
* 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 EmberObject, {computed} from '@ember/object';
import Key from 'mobiledoc-kit/utils/key';
import MobiledocRange from 'mobiledoc-kit/utils/cursor/range';
import defaultAtoms from '../options/atoms';
import defaultCards from '../options/cards';
import formatMarkdown from 'ghost-admin/utils/format-markdown';
import layout from '../templates/components/koenig-editor';
import registerKeyCommands from '../options/key-commands';
import registerTextExpansions from '../options/text-expansions';
import validator from 'npm:validator';
import {A} from '@ember/array';
import {MOBILEDOC_VERSION} from 'mobiledoc-kit/renderers/mobiledoc';
import {assign} from '@ember/polyfills';
import {camelize, capitalize} from '@ember/string';
import {copy} from '@ember/object/internals';
import {getContentFromPasteEvent} from 'mobiledoc-kit/utils/parse-utils';
import {getLinkMarkupFromRange} from '../utils/markup-utils';
import {getOwner} from '@ember/application';
import {guidFor} from '@ember/object/internals';
import {run} from '@ember/runloop';
const UNDO_DEPTH = 50;
export const ADD_CARD_HOOK = 'addComponent';
export const REMOVE_CARD_HOOK = 'removeComponent';
// 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, '']
]]
]
};
// map card names to component names
export const CARD_COMPONENT_MAP = {
hr: 'koenig-card-hr',
image: 'koenig-card-image',
markdown: 'koenig-card-markdown',
'card-markdown': 'koenig-card-markdown', // backwards-compat with markdown editor
html: 'koenig-card-html',
code: 'koenig-card-code'
};
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: '`'
};
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', 'w-100', 'flex-grow', 'relative', 'center', 'mb0', 'mt0'],
// public attrs
mobiledoc: null,
placeholder: 'Write here...',
autofocus: false,
spellcheck: true,
options: null,
scrollContainer: '',
headerOffset: 0,
// internal properties
editor: null,
activeMarkupTagNames: null,
activeSectionTagNames: null,
selectedRange: 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() {},
/* computed properties -------------------------------------------------- */
// merge in named options with the `options` property data-bag
// TODO: what is the `options` property data-bag and when/where does it get set?
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 = Array.concat(defaultAtoms, atoms);
cards = Array.concat(defaultCards, cards);
return assign({
placeholder: this.placeholder,
spellcheck: this.spellcheck,
autofocus: this.autofocus,
atoms,
cards
}, options);
}),
/* lifecycle hooks ------------------------------------------------------ */
init() {
this._super(...arguments);
// 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('componentCards', A([]));
this.set('activeMarkupTagNames', {});
this.set('activeSectionTagNames', {});
this._modifierKeys = {
shift: false,
alt: false,
ctrl: false
};
this._startedRunLoop = false;
},
willRender() {
// 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;
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 = copy(payload, true);
// all of the properties that will be passed through to the
// component cards via the template
let card = EmberObject.create({
cardName,
componentName,
koenigOptions,
payload,
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);
}
};
editorOptions.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();
}
});
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.isEditingDisabled) {
editor.disableEditing();
}
this.set('editor', editor);
this.didCreateEditor(editor);
},
didInsertElement() {
this._super(...arguments);
let editorElement = this.element.querySelector('[data-kg="editor"]');
this._pasteHandler = run.bind(this, this.handlePaste);
editorElement.addEventListener('paste', this._pasteHandler);
},
// 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() {
let editor = this.editor;
let editorElement = this.element.querySelector('[data-kg="editor"]');
editorElement.removeEventListener('paste', this._pasteHandler);
editor.destroy();
this._super(...arguments);
},
actions: {
toggleMarkup(markupTagName, postEditor) {
(postEditor || this.editor).toggleMarkup(markupTagName);
},
toggleSection(sectionTagName, postEditor) {
(postEditor || this.editor).toggleSection(sectionTagName);
},
toggleHeaderSection(headingTagName, postEditor) {
let editor = this.editor;
// skip toggle if we already have the same heading level
if (editor.activeSection.tagName === headingTagName) {
return;
}
let operation = function (postEditor) {
// strip all formatting aside from links
postEditor.removeMarkupFromRange(
editor.activeSection.toRange(),
m => m.tagName !== 'a'
);
postEditor.toggleSection(headingTagName);
};
this._performEdit(operation, postEditor);
},
replaceWithCardSection(cardName, range) {
let editor = this.editor;
let {head: {section}} = range;
editor.run((postEditor) => {
let {builder} = postEditor;
let card = builder.createCardSection(cardName);
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
run.schedule('afterRender', this, function () {
let card = this.componentCards.lastObject;
if (card.koenigOptions.hasEditMode) {
this.editCard(card);
} else if (card.koenigOptions.selectAfterInsert) {
this.selectCard(card);
}
});
},
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) {
let linkMarkup = getLinkMarkupFromRange(range);
if ((!range.isCollapsed || linkMarkup) && range.headSection.isMarkerable) {
this.set('linkRange', range);
}
},
cancelEditLink() {
this.set('linkRange', null);
},
deleteCard(card, cursorMovement = NO_CURSOR_MOVEMENT) {
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());
});
}
},
/* mobiledoc event handlers --------------------------------------------- */
postDidChange(editor) {
let serializeVersion = this.serializeVersion;
let updatedMobiledoc = editor.serialize(serializeVersion);
this._localMobiledoc = updatedMobiledoc;
// trigger closure action
this.onChange(updatedMobiledoc);
},
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);
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
// HACK: this is largely duplicated in `inputModeDidChange` to work
// around an event ordering bug - see comments there
if (isCollapsed && head.marker) {
Object.keys(SPECIAL_MARKUPS).forEach((tagName) => {
if (head.marker.hasMarkup(tagName)) {
let nextMarker = head.markerIn(1);
if (!nextMarker || !nextMarker.hasMarkup(tagName)) {
run.next(this, function () {
editor.toggleMarkup(tagName);
});
}
}
});
}
// 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) {
let finalSection = tail.section.prev;
let newRange = new MobiledocRange(head, finalSection.tailPosition());
return editor.run((postEditor) => {
postEditor.setRange(newRange);
});
}
// pass the selected range through to the toolbar + menu components
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);
// HACK: this is largly duplicated with our `cursorDidChange` handling.
// On keyboard cursor movement our `cursorDidChange` toggle for special
// formats happens before mobiledoc's readstate updates activeMarkups
// so we have to re-do it here
let {head, isCollapsed} = editor.range;
if (isCollapsed) {
let activeMarkupTagNames = editor.activeMarkups.mapBy('tagName');
Object.keys(SPECIAL_MARKUPS).forEach((tagName) => {
if (activeMarkupTagNames.includes(tagName.toLowerCase())) {
let nextMarker = head.markerIn(1);
if (!nextMarker || !nextMarker.hasMarkup(tagName)) {
return editor.toggleMarkup(tagName);
}
}
});
}
// 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);
}
},
/* custom event handlers ------------------------------------------------ */
handlePaste(event) {
let editor = this.editor;
let range = editor.range;
// if a URL is pasted and we have a selection, make that selection a link
if (range && !range.isCollapsed && range.headSection === range.tailSection && range.headSection.isMarkerable) {
let {text} = getContentFromPasteEvent(event);
if (text && validator.isURL(text)) {
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 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 IE/Edge which only ever expose `html`
let {html, text} = getContentFromPasteEvent(event);
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);
}
},
/* Ember event handlers ------------------------------------------------- */
// disable dragging
// TODO: needs testing for how this interacts with cards that have drag behaviour
dragStart(event) {
event.preventDefault();
},
// 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
keyDown(event) {
let key = Key.fromEvent(event);
this._updateModifiersFromKey(key, {isDown: true});
},
keyUp(event) {
let key = Key.fromEvent(event);
this._updateModifiersFromKey(key, {isDown: false});
},
/* 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);
},
deselectCard(card) {
card.set('isEditing', false);
card.set('isSelected', false);
this.selectedCard = null;
this._showCursor();
},
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);
},
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) {
this.editor.run((postEditor) => {
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;
}
postEditor.setRange(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((postEditor) => {
editOperation(postEditor);
});
}
},
_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;
}
},
// 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;
}
}
});