mirror of
https://github.com/TryGhost/Ghost-Admin.git
synced 2023-12-14 02:33:04 +01:00
✨ Koenig - Added rich-text support to captions
refs https://github.com/TryGhost/Ghost/issues/9724 - added `{{koenig-basic-html-input}}` component - uses a stripped down version of Koenig - supports all inline formatting that Koenig supports - supports inline text expansions - supports inline key commands - limited to a single paragraph - serialises and deserialises from HTML rather than mobiledoc - updated `{{koenig-caption-input}}` to use `{{koenig-basic-html-input}}` - updated image and embed cards to calculate word counts correctly for html captions - bumped Spirit dependency to fix styling of toolbars within the editor canvas - fixed positioning in toolbar components to account for `parentElement` not necessarily being the closest element to position against
This commit is contained in:
parent
9836c91b19
commit
09743cfb2b
23 changed files with 773 additions and 211 deletions
365
lib/koenig-editor/addon/components/koenig-basic-html-input.js
Normal file
365
lib/koenig-editor/addon/components/koenig-basic-html-input.js
Normal file
|
@ -0,0 +1,365 @@
|
|||
import Component from '@ember/component';
|
||||
import Editor from 'mobiledoc-kit/editor/editor';
|
||||
import layout from '../templates/components/koenig-basic-html-input';
|
||||
import parserPlugins from '../options/basic-html-parser-plugins';
|
||||
import registerKeyCommands, {BASIC_KEY_COMMANDS} from '../options/key-commands';
|
||||
import {MOBILEDOC_VERSION} from 'mobiledoc-kit/renderers/mobiledoc';
|
||||
import {arrayToMap, toggleSpecialFormatEditState} from './koenig-editor';
|
||||
import {assign} from '@ember/polyfills';
|
||||
import {cleanBasicHtml} from '../helpers/clean-basic-html';
|
||||
import {computed} from '@ember/object';
|
||||
import {getLinkMarkupFromRange} from '../utils/markup-utils';
|
||||
import {registerBasicTextExpansions} from '../options/text-expansions';
|
||||
import {run} from '@ember/runloop';
|
||||
|
||||
const UNDO_DEPTH = 50;
|
||||
|
||||
// blank doc contains a single empty paragraph so that there's some content for
|
||||
// the cursor to start in
|
||||
const BLANK_DOC = {
|
||||
version: MOBILEDOC_VERSION,
|
||||
markups: [],
|
||||
atoms: [],
|
||||
cards: [],
|
||||
sections: [
|
||||
[1, 'p', [
|
||||
[0, [], 0, '']
|
||||
]]
|
||||
]
|
||||
};
|
||||
|
||||
export default Component.extend({
|
||||
layout,
|
||||
|
||||
// public attrs
|
||||
autofocus: false,
|
||||
html: null,
|
||||
placeholder: '',
|
||||
spellcheck: true,
|
||||
|
||||
// internal properties
|
||||
activeMarkupTagNames: null,
|
||||
editor: null,
|
||||
linkRange: null,
|
||||
mobiledoc: null,
|
||||
selectedRange: null,
|
||||
|
||||
// private properties
|
||||
_hasFocus: false,
|
||||
_lastMobiledoc: null,
|
||||
_startedRunLoop: false,
|
||||
|
||||
// closure actions
|
||||
willCreateEditor() {},
|
||||
didCreateEditor() {},
|
||||
onChange() {},
|
||||
onNewline() {},
|
||||
onFocus() {},
|
||||
onBlur() {},
|
||||
|
||||
/* computed properties -------------------------------------------------- */
|
||||
|
||||
cleanHTML: computed('html', function () {
|
||||
return cleanBasicHtml(this.html);
|
||||
}),
|
||||
|
||||
// merge in named options with any passed in `options` property data-bag
|
||||
editorOptions: computed('cleanHTML', function () {
|
||||
let options = this.options || {};
|
||||
let atoms = this.atoms || [];
|
||||
let cards = this.cards || [];
|
||||
|
||||
return assign({
|
||||
html: `<p>${this.cleanHTML || ''}</p>`,
|
||||
placeholder: this.placeholder,
|
||||
spellcheck: this.spellcheck,
|
||||
autofocus: this.autofocus,
|
||||
cards,
|
||||
atoms,
|
||||
unknownCardHandler() {},
|
||||
unknownAtomHandler() {}
|
||||
}, options);
|
||||
}),
|
||||
|
||||
/* lifecycle hooks ------------------------------------------------------ */
|
||||
|
||||
didReceiveAttrs() {
|
||||
this._super(...arguments);
|
||||
|
||||
// reset local mobiledoc if html has been changed upstream so that
|
||||
// the html will be re-parsed by the mobiledoc-kit editor
|
||||
if (this.cleanHTML !== this._getHTML()) {
|
||||
this.set('mobiledoc', null);
|
||||
}
|
||||
},
|
||||
|
||||
willRender() {
|
||||
let mobiledoc = this.mobiledoc;
|
||||
|
||||
if (!mobiledoc && !this.cleanHTML) {
|
||||
mobiledoc = BLANK_DOC;
|
||||
}
|
||||
|
||||
let mobiledocIsSame =
|
||||
(this._lastMobiledoc && this._lastMobiledoc === 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;
|
||||
|
||||
// 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 = parserPlugins;
|
||||
|
||||
editor = new Editor(editorOptions);
|
||||
|
||||
registerKeyCommands(editor, this, BASIC_KEY_COMMANDS);
|
||||
registerBasicTextExpansions(editor);
|
||||
|
||||
// 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.willHandleNewline((event) => {
|
||||
run.join(() => {
|
||||
this.willHandleNewline(event);
|
||||
});
|
||||
});
|
||||
|
||||
editor.didUpdatePost((postEditor) => {
|
||||
run.join(() => {
|
||||
this.didUpdatePost(postEditor);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// update mobiledoc reference to match initial editor state from parsed
|
||||
// html. We use this value to compare on re-renders in case we need to
|
||||
// re-parse from html
|
||||
this.mobiledoc = editor.serialize();
|
||||
this._lastMobiledoc = this.mobiledoc;
|
||||
|
||||
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;
|
||||
if (!editor.hasRendered) {
|
||||
let editorElement = this.element.querySelector('[data-kg="editor"]');
|
||||
this._isRenderingEditor = true;
|
||||
editor.render(editorElement);
|
||||
this._isRenderingEditor = false;
|
||||
}
|
||||
},
|
||||
|
||||
willDestroyElement() {
|
||||
this._super(...arguments);
|
||||
this.editor.destroy();
|
||||
},
|
||||
|
||||
actions: {
|
||||
toggleMarkup(markupTagName, postEditor) {
|
||||
(postEditor || this.editor).toggleMarkup(markupTagName);
|
||||
},
|
||||
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
|
||||
/* ember event handlers --------------------------------------------------*/
|
||||
|
||||
// handle focusin/focusout at the component level so that we don't trigger blur
|
||||
// actions when clicking on toolbar buttons
|
||||
focusIn(event) {
|
||||
if (!this._hasFocus) {
|
||||
this._hasFocus = true;
|
||||
run.scheduleOnce('actions', this, this.onFocus, event);
|
||||
}
|
||||
},
|
||||
|
||||
focusOut(event) {
|
||||
if (!event.relatedTarget || !this.element.contains(event.relatedTarget)) {
|
||||
this._hasFocus = false;
|
||||
run.scheduleOnce('actions', this, this.onBlur, event);
|
||||
}
|
||||
},
|
||||
|
||||
/* mobiledoc event handlers ----------------------------------------------*/
|
||||
|
||||
willHandleNewline(event) {
|
||||
event.preventDefault();
|
||||
this.onNewline();
|
||||
},
|
||||
|
||||
// manipulate mobiledoc content before committing changes
|
||||
// - only one section
|
||||
// - first section must be a markerable section
|
||||
// - if first section is a list, grab the content of the first list item
|
||||
didUpdatePost(postEditor) {
|
||||
let {builder, editor, editor: {post}} = postEditor;
|
||||
|
||||
// remove any non-markerable sections
|
||||
post.sections.forEach((section) => {
|
||||
if (!section.isMarkerable && !section.isListSection) {
|
||||
let reposition = section === editor.activeSection;
|
||||
postEditor.removeSection(section);
|
||||
if (reposition) {
|
||||
postEditor.setRange(post.sections.head.tailPosition());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// strip all sections other than the first
|
||||
if (post.sections.length > 1) {
|
||||
while (post.sections.length > 1) {
|
||||
postEditor.removeSection(post.sections.tail);
|
||||
}
|
||||
postEditor.setRange(post.sections.head.tailPosition());
|
||||
}
|
||||
|
||||
// convert list section to a paragraph section
|
||||
if (post.sections.head.isListSection) {
|
||||
let list = post.sections.head;
|
||||
let listItem = list.items.head;
|
||||
let newMarkers = listItem.markers.map(m => m.clone());
|
||||
let p = builder.createMarkupSection('p', newMarkers);
|
||||
postEditor.replaceSection(list, p);
|
||||
postEditor.setRange(post.sections.head.tailPosition());
|
||||
}
|
||||
},
|
||||
|
||||
postDidChange() {
|
||||
// trigger closure action
|
||||
this.onChange(this._getHTML());
|
||||
},
|
||||
|
||||
cursorDidChange(editor) {
|
||||
// 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);
|
||||
|
||||
// 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));
|
||||
|
||||
// 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);
|
||||
});
|
||||
} else {
|
||||
this.set('activeMarkupTagNames', markupTags);
|
||||
}
|
||||
},
|
||||
|
||||
/* private methods -------------------------------------------------------*/
|
||||
|
||||
// rather than parsing mobiledoc to HTML we can grab the HTML directly from
|
||||
// inside the editor element because we should only be dealing with
|
||||
// inline markup that directly maps to HTML elements
|
||||
_getHTML() {
|
||||
if (this.editor && this.editor.element) {
|
||||
let firstParagraph = this.editor.element.querySelector('p');
|
||||
|
||||
if (!firstParagraph) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let html = firstParagraph.innerHTML;
|
||||
return cleanBasicHtml(html);
|
||||
}
|
||||
}
|
||||
});
|
|
@ -22,7 +22,7 @@ export default Component.extend({
|
|||
moveCursorToPrevSection() {},
|
||||
|
||||
figCaptionClass: computed(function () {
|
||||
return `${kgStyle(['figcaption'])} w-100`;
|
||||
return `${kgStyle(['figcaption'])} w-100 relative`;
|
||||
}),
|
||||
|
||||
didReceiveAttrs() {
|
||||
|
@ -42,21 +42,43 @@ export default Component.extend({
|
|||
this._detachHandlers();
|
||||
},
|
||||
|
||||
actions: {
|
||||
registerEditor(editor) {
|
||||
let commands = {
|
||||
ENTER: run.bind(this, this._enter),
|
||||
ESC: run.bind(this, this._escape),
|
||||
UP: run.bind(this, this._upOrLeft),
|
||||
LEFT: run.bind(this, this._upOrLeft),
|
||||
DOWN: run.bind(this, this._rightOrDown),
|
||||
RIGHT: run.bind(this, this._rightOrDown)
|
||||
};
|
||||
|
||||
Object.keys(commands).forEach((str) => {
|
||||
editor.registerKeyCommand({
|
||||
str,
|
||||
run() {
|
||||
return commands[str](editor, str);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.editor = editor;
|
||||
},
|
||||
|
||||
handleEnter() {
|
||||
this.addParagraphAfterCard();
|
||||
}
|
||||
},
|
||||
|
||||
_attachHandlers() {
|
||||
if (!this._keypressHandler) {
|
||||
this._keypressHandler = run.bind(this, this._handleKeypress);
|
||||
window.addEventListener('keypress', this._keypressHandler);
|
||||
}
|
||||
|
||||
if (!this._keydownHandler) {
|
||||
this._keydownHandler = run.bind(this, this._handleKeydown);
|
||||
window.addEventListener('keydown', this._keydownHandler);
|
||||
}
|
||||
},
|
||||
|
||||
_detachHandlers() {
|
||||
window.removeEventListener('keypress', this._keypressHandler);
|
||||
window.removeEventListener('keydown', this._keydownHandler);
|
||||
this._keypressHandler = null;
|
||||
this._keydownHandler = null;
|
||||
},
|
||||
|
@ -64,51 +86,58 @@ export default Component.extend({
|
|||
// only fires if the card is selected, moves focus to the caption input so
|
||||
// that it's possible to start typing without explicitly focusing the input
|
||||
_handleKeypress(event) {
|
||||
let captionInput = this.element.querySelector('[name="caption"]');
|
||||
let key = new Key(event);
|
||||
let {editor} = this;
|
||||
|
||||
if (event.target.matches('[data-kg="editor"]') && editor && !editor._hasFocus() && key.isPrintableKey()) {
|
||||
editor.focus();
|
||||
editor.run((postEditor) => {
|
||||
postEditor.insertText(editor.post.tailPosition(), event.key);
|
||||
});
|
||||
|
||||
if (captionInput && captionInput !== document.activeElement && key.isPrintableKey()) {
|
||||
captionInput.value = `${captionInput.value}${event.key}`;
|
||||
captionInput.focus();
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
|
||||
// this will be fired for keydown events when the caption input is focused,
|
||||
// we look for cursor movements or the enter key to defocus and trigger the
|
||||
// corresponding editor behaviour
|
||||
_handleKeydown(event) {
|
||||
let captionInput = this.element.querySelector('[name="caption"]');
|
||||
/* key commands ----------------------------------------------------------*/
|
||||
|
||||
if (event.target === captionInput) {
|
||||
if (event.key === 'Escape') {
|
||||
captionInput.blur();
|
||||
return;
|
||||
}
|
||||
_enter() {
|
||||
this.send('handleEnter');
|
||||
},
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
captionInput.blur();
|
||||
this.addParagraphAfterCard();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
_escape(editor) {
|
||||
editor.blur();
|
||||
},
|
||||
|
||||
let selectionStart = captionInput.selectionStart;
|
||||
let length = captionInput.value.length;
|
||||
_upOrLeft(editor, key) {
|
||||
let {isCollapsed, head} = editor.range;
|
||||
|
||||
if ((event.key === 'ArrowUp' || event.key === 'ArrowLeft') && selectionStart === 0) {
|
||||
captionInput.blur();
|
||||
this.moveCursorToPrevSection();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((event.key === 'ArrowDown' || event.key === 'ArrowRight') && selectionStart === length) {
|
||||
captionInput.blur();
|
||||
this.moveCursorToNextSection();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (isCollapsed && head.isEqual(head.section.headPosition())) {
|
||||
return this.moveCursorToPrevSection();
|
||||
}
|
||||
|
||||
// we're simulating a text input so up/down move the cursor to the
|
||||
// beginning/end of the input
|
||||
if (isCollapsed && key === 'UP') {
|
||||
return editor.selectRange(head.section.headPosition().toRange());
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
_rightOrDown(editor, key) {
|
||||
let {isCollapsed, tail} = editor.range;
|
||||
|
||||
if (isCollapsed && tail.isEqual(tail.section.tailPosition())) {
|
||||
return this.moveCursorToNextSection();
|
||||
}
|
||||
|
||||
// we're simulating a text input so up/down move the cursor to the
|
||||
// beginning/end of the input
|
||||
if (isCollapsed && key === 'DOWN') {
|
||||
return editor.selectRange(tail.section.tailPosition().toRange());
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import Component from '@ember/component';
|
||||
import countWords from '../utils/count-words';
|
||||
import countWords, {stripTags} from '../utils/count-words';
|
||||
import layout from '../templates/components/koenig-card-embed';
|
||||
import noframe from 'noframe.js';
|
||||
import {NO_CURSOR_MOVEMENT} from './koenig-editor';
|
||||
|
@ -38,7 +38,7 @@ export default Component.extend({
|
|||
counts: computed('payload.{html,caption}', function () {
|
||||
return {
|
||||
imageCount: this.payload.html ? 1 : 0,
|
||||
wordCount: countWords(this.payload.caption)
|
||||
wordCount: countWords(stripTags(this.payload.caption))
|
||||
};
|
||||
}),
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import $ from 'jquery';
|
||||
import Component from '@ember/component';
|
||||
import countWords from '../utils/count-words';
|
||||
import countWords, {stripTags} from '../utils/count-words';
|
||||
import layout from '../templates/components/koenig-card-image';
|
||||
import {
|
||||
IMAGE_EXTENSIONS,
|
||||
|
@ -46,7 +46,7 @@ export default Component.extend({
|
|||
}
|
||||
|
||||
if (this.payload.caption) {
|
||||
wordCount += countWords(this.payload.caption);
|
||||
wordCount += countWords(stripTags(this.payload.caption));
|
||||
}
|
||||
|
||||
return {wordCount, imageCount};
|
||||
|
|
|
@ -76,7 +76,7 @@ export const SPECIAL_MARKUPS = {
|
|||
SUB: '~'
|
||||
};
|
||||
|
||||
function arrayToMap(array) {
|
||||
export function arrayToMap(array) {
|
||||
let map = Object.create(null);
|
||||
array.forEach((key) => {
|
||||
if (key) { // skip undefined/falsy key values
|
||||
|
@ -91,7 +91,7 @@ function arrayToMap(array) {
|
|||
// 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
|
||||
function toggleSpecialFormatEditState(editor) {
|
||||
export function toggleSpecialFormatEditState(editor) {
|
||||
let {head, isCollapsed} = editor.range;
|
||||
if (isCollapsed) {
|
||||
Object.keys(SPECIAL_MARKUPS).forEach((tagName) => {
|
||||
|
|
|
@ -193,7 +193,7 @@ export default Component.extend({
|
|||
|
||||
// TODO: largely shared with {{koenig-toolbar}} code - extract to a shared util?
|
||||
_positionToolbar() {
|
||||
let containerRect = this.element.parentNode.getBoundingClientRect();
|
||||
let containerRect = this.element.offsetParent.getBoundingClientRect();
|
||||
let rangeRect = this._windowRange.getBoundingClientRect();
|
||||
let {width, height} = this.element.getBoundingClientRect();
|
||||
let newPosition = {};
|
||||
|
|
|
@ -90,6 +90,7 @@ export default Component.extend({
|
|||
this._super(...arguments);
|
||||
|
||||
let container = this.container;
|
||||
container.dataset.kgHasLinkToolbar = true;
|
||||
this._addEventListener(container, 'mouseover', this._handleMouseover);
|
||||
this._addEventListener(container, 'mouseout', this._handleMouseout);
|
||||
},
|
||||
|
@ -142,7 +143,7 @@ export default Component.extend({
|
|||
_handleMouseover(event) {
|
||||
if (this._canShowToolbar) {
|
||||
let target = getEventTargetMatchingTag('a', event.target, this.container);
|
||||
if (target && target.isContentEditable) {
|
||||
if (target && target.isContentEditable && target.closest('[data-kg-has-link-toolbar=true]') === this.container) {
|
||||
this._timeout = run.later(this, function () {
|
||||
this._showToolbar(target);
|
||||
}, DELAY);
|
||||
|
@ -178,7 +179,7 @@ export default Component.extend({
|
|||
},
|
||||
|
||||
_positionToolbar(target) {
|
||||
let containerRect = this.element.parentNode.getBoundingClientRect();
|
||||
let containerRect = this.element.offsetParent.getBoundingClientRect();
|
||||
let targetRect = target.getBoundingClientRect();
|
||||
let {width, height} = this.element.getBoundingClientRect();
|
||||
let newPosition = {};
|
||||
|
|
|
@ -26,6 +26,7 @@ export default Component.extend({
|
|||
classNames: ['absolute', 'z-999'],
|
||||
|
||||
// public attrs
|
||||
basicOnly: false,
|
||||
editor: null,
|
||||
editorRange: null,
|
||||
activeMarkupTagNames: null,
|
||||
|
@ -235,7 +236,7 @@ export default Component.extend({
|
|||
},
|
||||
|
||||
_positionToolbar() {
|
||||
let containerRect = this.element.parentNode.getBoundingClientRect();
|
||||
let containerRect = this.element.offsetParent.getBoundingClientRect();
|
||||
let range = window.getSelection().getRangeAt(0);
|
||||
let rangeRect = range.getBoundingClientRect();
|
||||
let {width, height} = this.element.getBoundingClientRect();
|
||||
|
|
32
lib/koenig-editor/addon/helpers/clean-basic-html.js
Normal file
32
lib/koenig-editor/addon/helpers/clean-basic-html.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
import {helper} from '@ember/component/helper';
|
||||
import {isArray} from '@ember/array';
|
||||
|
||||
export function cleanBasicHtml(html = '') {
|
||||
if (isArray(html)) {
|
||||
html = html[0];
|
||||
}
|
||||
|
||||
let cleanHtml = html
|
||||
.replace(/<br>/g, ' ')
|
||||
.replace(/(\s| ){2,}/g, ' ')
|
||||
.trim()
|
||||
.replace(/^ | $/g, '')
|
||||
.trim();
|
||||
|
||||
// remove any elements that have a blank textContent
|
||||
if (cleanHtml) {
|
||||
let doc = new DOMParser().parseFromString(cleanHtml, 'text/html');
|
||||
|
||||
doc.body.querySelectorAll('*').forEach((element) => {
|
||||
if (!element.textContent.trim()) {
|
||||
element.remove();
|
||||
}
|
||||
});
|
||||
|
||||
cleanHtml = doc.body.innerHTML;
|
||||
}
|
||||
|
||||
return cleanHtml;
|
||||
}
|
||||
|
||||
export default helper(cleanBasicHtml);
|
12
lib/koenig-editor/addon/options/basic-html-parser-plugins.js
Normal file
12
lib/koenig-editor/addon/options/basic-html-parser-plugins.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
export function removeBR(node, builder, {addMarkerable, nodeFinished}) {
|
||||
if (node.nodeType !== 1 || node.tagName !== 'BR') {
|
||||
return;
|
||||
}
|
||||
|
||||
addMarkerable(builder.createMarker(' '));
|
||||
nodeFinished();
|
||||
}
|
||||
|
||||
export default [
|
||||
removeBR
|
||||
];
|
|
@ -406,8 +406,21 @@ export const DEFAULT_KEY_COMMANDS = [{
|
|||
}
|
||||
}];
|
||||
|
||||
export default function registerKeyCommands(editor, koenig) {
|
||||
DEFAULT_KEY_COMMANDS.forEach((keyCommand) => {
|
||||
// key commands that are used in koenig-basic-html-input
|
||||
export const BASIC_KEY_COMMANDS = DEFAULT_KEY_COMMANDS.filter((command) => {
|
||||
let basicCommands = [
|
||||
'BACKSPACE',
|
||||
'CTRL+K',
|
||||
'META+K',
|
||||
'CTRL+ALT+U',
|
||||
'CTRL+SHIFT+K',
|
||||
'META+SHIFT+K'
|
||||
];
|
||||
return basicCommands.includes(command.str);
|
||||
});
|
||||
|
||||
export default function registerKeyCommands(editor, koenig, commands = DEFAULT_KEY_COMMANDS) {
|
||||
commands.forEach((keyCommand) => {
|
||||
editor.registerKeyCommand({
|
||||
str: keyCommand.str,
|
||||
run() {
|
||||
|
|
|
@ -53,119 +53,7 @@ export function replaceWithListSection(editor, matches, listTagName) {
|
|||
});
|
||||
}
|
||||
|
||||
export default function (editor, koenig) {
|
||||
/* block level markdown ------------------------------------------------- */
|
||||
|
||||
editor.unregisterTextInputHandler('heading');
|
||||
editor.onTextInput({
|
||||
name: 'md_heading',
|
||||
match: /^(#{1,6}) /,
|
||||
run(editor, matches) {
|
||||
let hashes = matches[1];
|
||||
let headingTag = `h${hashes.length}`;
|
||||
let {range} = editor;
|
||||
let text = range.head.section.textUntil(range.head);
|
||||
|
||||
// we don't want to convert to a heading if the user has not just
|
||||
// finished typing the markdown (eg, they've made a previous
|
||||
// heading expansion then Cmd-Z'ed it to get the text back then
|
||||
// starts typing at the end of the heading)
|
||||
if (text !== matches[0]) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.run((postEditor) => {
|
||||
range = range.extend(-(matches[0].length));
|
||||
let position = postEditor.deleteRange(range);
|
||||
postEditor.setRange(position);
|
||||
|
||||
// toggleHeaderSection will remove all formatting except links
|
||||
koenig.send('toggleHeaderSection', headingTag, postEditor);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
editor.unregisterTextInputHandler('ul');
|
||||
editor.onTextInput({
|
||||
name: 'md_ul',
|
||||
match: /^\* |^- /,
|
||||
run(editor, matches) {
|
||||
replaceWithListSection(editor, matches, 'ul');
|
||||
}
|
||||
});
|
||||
|
||||
editor.unregisterTextInputHandler('ol');
|
||||
editor.onTextInput({
|
||||
name: 'md_ol',
|
||||
match: /^1\.? /,
|
||||
run(editor, matches) {
|
||||
replaceWithListSection(editor, matches, 'ol');
|
||||
}
|
||||
});
|
||||
|
||||
editor.onTextInput({
|
||||
name: 'md_blockquote',
|
||||
match: /^> /,
|
||||
run(editor, matches) {
|
||||
let {range} = editor;
|
||||
let {head, head: {section}} = range;
|
||||
let text = section.textUntil(head);
|
||||
|
||||
// ensure cursor is at the end of the matched text so we don't
|
||||
// convert text the users wants to start with `> ` and that we're
|
||||
// not already on a blockquote section
|
||||
if (text === matches[0] && section.tagName !== 'blockquote') {
|
||||
editor.run((postEditor) => {
|
||||
range = range.extend(-(matches[0].length));
|
||||
let position = postEditor.deleteRange(range);
|
||||
postEditor.setRange(position);
|
||||
|
||||
koenig.send('toggleSection', 'blockquote', postEditor);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
editor.onTextInput({
|
||||
name: 'md_hr',
|
||||
match: /^---$/,
|
||||
run(editor) {
|
||||
let {range: {head, head: {section}}} = editor;
|
||||
|
||||
// Skip if cursor is not at end of section
|
||||
if (!head.isTail()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if section is a list item
|
||||
if (section.isListItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
koenig.send('replaceWithCardSection', 'hr', section.toRange());
|
||||
}
|
||||
});
|
||||
|
||||
editor.onTextInput({
|
||||
name: 'md_code',
|
||||
match: /^```$/,
|
||||
run(editor) {
|
||||
let {range: {head, head: {section}}} = editor;
|
||||
|
||||
// Skip if cursor is not at end of section
|
||||
if (!head.isTail()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if section is a list item
|
||||
if (section.isListItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
koenig.send('replaceWithCardSection', 'code', section.toRange());
|
||||
}
|
||||
}),
|
||||
|
||||
function registerInlineMarkdownTextExpansions(editor) {
|
||||
/* inline markdown ------------------------------------------------------ */
|
||||
|
||||
// --\s = en dash –
|
||||
|
@ -487,3 +375,130 @@ export default function (editor, koenig) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function (editor, koenig) {
|
||||
/* inline markdown -------------------------------------------------------*/
|
||||
registerInlineMarkdownTextExpansions(editor);
|
||||
|
||||
/* block level markdown ------------------------------------------------- */
|
||||
|
||||
editor.unregisterTextInputHandler('heading');
|
||||
editor.onTextInput({
|
||||
name: 'md_heading',
|
||||
match: /^(#{1,6}) /,
|
||||
run(editor, matches) {
|
||||
let hashes = matches[1];
|
||||
let headingTag = `h${hashes.length}`;
|
||||
let {range} = editor;
|
||||
let text = range.head.section.textUntil(range.head);
|
||||
|
||||
// we don't want to convert to a heading if the user has not just
|
||||
// finished typing the markdown (eg, they've made a previous
|
||||
// heading expansion then Cmd-Z'ed it to get the text back then
|
||||
// starts typing at the end of the heading)
|
||||
if (text !== matches[0]) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.run((postEditor) => {
|
||||
range = range.extend(-(matches[0].length));
|
||||
let position = postEditor.deleteRange(range);
|
||||
postEditor.setRange(position);
|
||||
|
||||
// toggleHeaderSection will remove all formatting except links
|
||||
koenig.send('toggleHeaderSection', headingTag, postEditor);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
editor.unregisterTextInputHandler('ul');
|
||||
editor.onTextInput({
|
||||
name: 'md_ul',
|
||||
match: /^\* |^- /,
|
||||
run(editor, matches) {
|
||||
replaceWithListSection(editor, matches, 'ul');
|
||||
}
|
||||
});
|
||||
|
||||
editor.unregisterTextInputHandler('ol');
|
||||
editor.onTextInput({
|
||||
name: 'md_ol',
|
||||
match: /^1\.? /,
|
||||
run(editor, matches) {
|
||||
replaceWithListSection(editor, matches, 'ol');
|
||||
}
|
||||
});
|
||||
|
||||
editor.onTextInput({
|
||||
name: 'md_blockquote',
|
||||
match: /^> /,
|
||||
run(editor, matches) {
|
||||
let {range} = editor;
|
||||
let {head, head: {section}} = range;
|
||||
let text = section.textUntil(head);
|
||||
|
||||
// ensure cursor is at the end of the matched text so we don't
|
||||
// convert text the users wants to start with `> ` and that we're
|
||||
// not already on a blockquote section
|
||||
if (text === matches[0] && section.tagName !== 'blockquote') {
|
||||
editor.run((postEditor) => {
|
||||
range = range.extend(-(matches[0].length));
|
||||
let position = postEditor.deleteRange(range);
|
||||
postEditor.setRange(position);
|
||||
|
||||
koenig.send('toggleSection', 'blockquote', postEditor);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
editor.onTextInput({
|
||||
name: 'md_hr',
|
||||
match: /^---$/,
|
||||
run(editor) {
|
||||
let {range: {head, head: {section}}} = editor;
|
||||
|
||||
// Skip if cursor is not at end of section
|
||||
if (!head.isTail()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if section is a list item
|
||||
if (section.isListItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
koenig.send('replaceWithCardSection', 'hr', section.toRange());
|
||||
}
|
||||
});
|
||||
|
||||
editor.onTextInput({
|
||||
name: 'md_code',
|
||||
match: /^```$/,
|
||||
run(editor) {
|
||||
let {range: {head, head: {section}}} = editor;
|
||||
|
||||
// Skip if cursor is not at end of section
|
||||
if (!head.isTail()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if section is a list item
|
||||
if (section.isListItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
koenig.send('replaceWithCardSection', 'code', section.toRange());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: reduce duplication
|
||||
export function registerBasicTextExpansions(editor) {
|
||||
// unregister mobiledoc-kit's block-level text handlers
|
||||
editor.unregisterTextInputHandler('heading');
|
||||
editor.unregisterTextInputHandler('ul');
|
||||
editor.unregisterTextInputHandler('ol');
|
||||
|
||||
registerInlineMarkdownTextExpansions(editor);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
<div class="koenig-basic-html-input__editor-wrappper">
|
||||
<div
|
||||
class="koenig-basic-html-input__editor"
|
||||
data-gramm="false"
|
||||
data-kg="editor"
|
||||
data-placeholder={{placeholder}}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{{koenig-toolbar
|
||||
basicOnly=true
|
||||
editor=editor
|
||||
editorRange=selectedRange
|
||||
activeMarkupTagNames=activeMarkupTagNames
|
||||
toggleMarkup=(action "toggleMarkup")
|
||||
editLink=(action "editLink")
|
||||
}}
|
||||
|
||||
{{!-- pop-up link hover toolbar --}}
|
||||
{{koenig-link-toolbar
|
||||
editor=editor
|
||||
container=element
|
||||
linkRange=linkRange
|
||||
selectedRange=selectedRange
|
||||
editLink=(action "editLink")
|
||||
}}
|
||||
|
||||
{{!-- pop-up link editing toolbar --}}
|
||||
{{#if linkRange}}
|
||||
{{koenig-link-input
|
||||
editor=editor
|
||||
linkRange=linkRange
|
||||
selectedRange=selectedRange
|
||||
cancel=(action "cancelEditLink")
|
||||
}}
|
||||
{{/if}}
|
|
@ -1,10 +1,11 @@
|
|||
<input
|
||||
placeholder={{if isFocused "" placeholder}}
|
||||
value={{caption}}
|
||||
type="text"
|
||||
{{koenig-basic-html-input
|
||||
html=caption
|
||||
placeholder=(if isFocused "" placeholder)
|
||||
class="miw-100 tc bn form-text bg-transparent"
|
||||
name="caption"
|
||||
oninput={{action update value="target.value"}}
|
||||
onfocus={{action (mut isFocused) true}}
|
||||
onblur={{action (mut isFocused) false}}
|
||||
>
|
||||
onChange=(action update)
|
||||
onFocus=(action (mut isFocused) true)
|
||||
onBlur=(action (mut isFocused) false)
|
||||
onNewline=(action "handleEnter")
|
||||
didCreateEditor=(action "registerEditor")
|
||||
}}
|
|
@ -22,7 +22,7 @@
|
|||
<div class="koenig-card-click-overlay ba b--white" data-kg-overlay></div>
|
||||
</div>
|
||||
|
||||
{{#if (or isSelected payload.caption)}}
|
||||
{{#if (or isSelected (clean-basic-html payload.caption))}}
|
||||
{{card.captionInput
|
||||
caption=payload.caption
|
||||
update=(action "updateCaption")
|
||||
|
|
|
@ -64,7 +64,7 @@
|
|||
</div>
|
||||
{{/gh-uploader}}
|
||||
|
||||
{{#if (or isSelected payload.caption)}}
|
||||
{{#if (or isSelected (clean-basic-html payload.caption))}}
|
||||
{{card.captionInput
|
||||
caption=payload.caption
|
||||
update=(action "updateCaption")
|
||||
|
|
|
@ -19,39 +19,43 @@
|
|||
{{svg-jar "koenig/kg-italic" class=(concat (if (or activeMarkupTagNames.isEm activeMarkupTagNames.isI) "fill-blue-l2" "fill-white") " w4 h4")}}
|
||||
</button>
|
||||
</li>
|
||||
<li class="ma0 lh-solid">
|
||||
<button
|
||||
type="button"
|
||||
title="Heading One"
|
||||
class="dib dim-lite link h10 w9 nudge-top--1"
|
||||
{{action "toggleHeaderSection" "h1"}}
|
||||
>
|
||||
{{svg-jar "koenig/kg-heading-1" class=(concat (if activeSectionTagNames.isH1 "fill-blue-l2" "fill-white") " w4 h4")}}
|
||||
</button>
|
||||
</li>
|
||||
<li class="ma0 lh-solid">
|
||||
<button
|
||||
type="button"
|
||||
title="Heading Two"
|
||||
class="dib dim-lite link h10 w9 nudge-top--1"
|
||||
{{action "toggleHeaderSection" "h2"}}
|
||||
>
|
||||
{{svg-jar "koenig/kg-heading-2" class=(concat (if activeSectionTagNames.isH2 "fill-blue-l2" "fill-white") " w4 h4")}}
|
||||
</button>
|
||||
</li>
|
||||
{{#unless basicOnly}}
|
||||
<li class="ma0 lh-solid">
|
||||
<button
|
||||
type="button"
|
||||
title="Heading One"
|
||||
class="dib dim-lite link h10 w9 nudge-top--1"
|
||||
{{action "toggleHeaderSection" "h1"}}
|
||||
>
|
||||
{{svg-jar "koenig/kg-heading-1" class=(concat (if activeSectionTagNames.isH1 "fill-blue-l2" "stroke-white") " w4 h4")}}
|
||||
</button>
|
||||
</li>
|
||||
<li class="ma0 lh-solid">
|
||||
<button
|
||||
type="button"
|
||||
title="Heading Two"
|
||||
class="dib dim-lite link h10 w9 nudge-top--1"
|
||||
{{action "toggleHeaderSection" "h2"}}
|
||||
>
|
||||
{{svg-jar "koenig/kg-heading-2" class=(concat (if activeSectionTagNames.isH2 "fill-blue-l2" "stroke-white") " w4 h4")}}
|
||||
</button>
|
||||
</li>
|
||||
{{/unless}}
|
||||
|
||||
<li class="ma0 lh-solid kg-action-bar-divider bg-darkgrey-l2 h5"></li>
|
||||
|
||||
<li class="ma0 lh-solid">
|
||||
<button
|
||||
type="button"
|
||||
title="Quote"
|
||||
class="dib dim-lite link h10 w9 nudge-top--1"
|
||||
{{action "toggleSection" "blockquote"}}
|
||||
>
|
||||
{{svg-jar "koenig/kg-quote" class=(concat (if activeSectionTagNames.isBlockquote "fill-blue-l2" "fill-white") " w4 h4")}}
|
||||
</button>
|
||||
</li>
|
||||
{{#unless basicOnly}}
|
||||
<li class="ma0 lh-solid">
|
||||
<button
|
||||
type="button"
|
||||
title="Quote"
|
||||
class="dib dim-lite link h10 w9 nudge-top--1"
|
||||
{{action "toggleSection" "blockquote"}}
|
||||
>
|
||||
{{svg-jar "koenig/kg-quote" class=(concat (if activeSectionTagNames.isBlockquote "fill-blue-l2" "stroke-white") " w4 h4")}}
|
||||
</button>
|
||||
</li>
|
||||
{{/unless}}
|
||||
<li class="ma0 lh-solid">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export {default} from 'koenig-editor/components/koenig-basic-html-input';
|
1
lib/koenig-editor/app/helpers/clean-basic-html.js
Normal file
1
lib/koenig-editor/app/helpers/clean-basic-html.js
Normal file
|
@ -0,0 +1 @@
|
|||
export {default, cleanBasicHtml} from 'koenig-editor/helpers/clean-basic-html';
|
|
@ -100,7 +100,7 @@
|
|||
"eslint": "4.19.1",
|
||||
"eslint-plugin-ghost": "0.0.25",
|
||||
"fs-extra": "4.0.3",
|
||||
"ghost-spirit": "0.0.30",
|
||||
"ghost-spirit": "0.0.31",
|
||||
"glob": "7.1.2",
|
||||
"google-caja-bower": "https://github.com/acburdine/google-caja-bower#ghost",
|
||||
"grunt": "1.0.3",
|
||||
|
|
24
tests/integration/components/koenig-basic-html-input-test.js
Normal file
24
tests/integration/components/koenig-basic-html-input-test.js
Normal file
|
@ -0,0 +1,24 @@
|
|||
import hbs from 'htmlbars-inline-precompile';
|
||||
import {describe, it} from 'mocha';
|
||||
import {expect} from 'chai';
|
||||
import {setupComponentTest} from 'ember-mocha';
|
||||
|
||||
describe('Integration: Component: koenig-basic-html-input', function () {
|
||||
setupComponentTest('koenig-basic-html-input', {
|
||||
integration: true
|
||||
});
|
||||
|
||||
it.skip('renders', function () {
|
||||
// Set any properties with this.set('myProperty', 'value');
|
||||
// Handle any actions with this.on('myAction', function(val) { ... });
|
||||
// Template block usage:
|
||||
// this.render(hbs`
|
||||
// {{#koenig-basic-html-input}}
|
||||
// template content
|
||||
// {{/koenig-basic-html-input}}
|
||||
// `);
|
||||
|
||||
this.render(hbs`{{koenig-basic-html-input}}`);
|
||||
expect(this.$()).to.have.length(1);
|
||||
});
|
||||
});
|
27
tests/integration/helpers/clean-basic-html-test.js
Normal file
27
tests/integration/helpers/clean-basic-html-test.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
import hbs from 'htmlbars-inline-precompile';
|
||||
import {describe, it} from 'mocha';
|
||||
import {expect} from 'chai';
|
||||
import {setupComponentTest} from 'ember-mocha';
|
||||
|
||||
describe('Integration: Helper: clean-basic-html', function () {
|
||||
setupComponentTest('clean-basic-html', {
|
||||
integration: true
|
||||
});
|
||||
|
||||
it('renders', function () {
|
||||
// Set any properties with this.set('myProperty', 'value');
|
||||
// Handle any actions with this.on('myAction', function(val) { ... });
|
||||
// Template block usage:
|
||||
// this.render(hbs`
|
||||
// {{#clean-basic-html}}
|
||||
// template content
|
||||
// {{/clean-basic-html}}
|
||||
// `);
|
||||
this.set('inputValue', '1234');
|
||||
|
||||
this.render(hbs`{{clean-basic-html inputValue}}`);
|
||||
|
||||
expect(this.$().text().trim()).to.equal('1234');
|
||||
});
|
||||
});
|
||||
|
|
@ -5241,9 +5241,9 @@ ghost-ignition@^2.7.0:
|
|||
prettyjson "^1.1.3"
|
||||
uuid "^3.0.0"
|
||||
|
||||
ghost-spirit@0.0.30:
|
||||
version "0.0.30"
|
||||
resolved "https://registry.yarnpkg.com/ghost-spirit/-/ghost-spirit-0.0.30.tgz#73f520363fbb0a4ac63483072bec9f01cc84d3a7"
|
||||
ghost-spirit@0.0.31:
|
||||
version "0.0.31"
|
||||
resolved "https://registry.yarnpkg.com/ghost-spirit/-/ghost-spirit-0.0.31.tgz#2a79ffc22d546ba34200417d166496fa51373707"
|
||||
dependencies:
|
||||
autoprefixer "8.2.0"
|
||||
bluebird "^3.4.6"
|
||||
|
|
Loading…
Reference in a new issue