1
0
Fork 0
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:
Kevin Ansfield 2018-08-08 12:53:35 +01:00
parent 9836c91b19
commit 09743cfb2b
23 changed files with 773 additions and 211 deletions

View 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);
}
}
});

View file

@ -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;
}
});

View file

@ -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))
};
}),

View file

@ -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};

View file

@ -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) => {

View file

@ -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 = {};

View file

@ -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 = {};

View file

@ -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();

View 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|&nbsp;){2,}/g, ' ')
.trim()
.replace(/^&nbsp;|&nbsp$/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);

View 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
];

View file

@ -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() {

View file

@ -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);
}

View file

@ -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}}

View file

@ -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")
}}

View file

@ -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")

View file

@ -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")

View file

@ -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"

View file

@ -0,0 +1 @@
export {default} from 'koenig-editor/components/koenig-basic-html-input';

View file

@ -0,0 +1 @@
export {default, cleanBasicHtml} from 'koenig-editor/helpers/clean-basic-html';

View file

@ -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",

View 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);
});
});

View 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');
});
});

View file

@ -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"