diff --git a/ghost/admin/app/styles/spirit/_koenig.css b/ghost/admin/app/styles/spirit/_koenig.css index 3c00aa0e5d..7c1d792e07 100644 --- a/ghost/admin/app/styles/spirit/_koenig.css +++ b/ghost/admin/app/styles/spirit/_koenig.css @@ -18,7 +18,8 @@ -moz-font-feature-settings: "liga" on; } -.koenig-editor__editor.__has-no-content:after { +.koenig-editor__editor.__has-no-content:after, +.koenig-text-replacement-html-input__editor.__has-no-content:after{ font-family: georgia,serif; font-weight: 300; letter-spacing: .02rem; @@ -863,7 +864,6 @@ left: -16px; } - /* Cards /* --------------------------------------------------------------- */ .kg-bookmark-card { @@ -959,6 +959,10 @@ line-height: 1.65em; } +.kg-email-card p:first-of-type { + margin-top: 0; +} + /* Codemirror overrides /* --------------------------------------------------------------- */ diff --git a/ghost/admin/lib/koenig-editor/addon/components/koenig-card-email.js b/ghost/admin/lib/koenig-editor/addon/components/koenig-card-email.js new file mode 100644 index 0000000000..bec9f503e1 --- /dev/null +++ b/ghost/admin/lib/koenig-editor/addon/components/koenig-card-email.js @@ -0,0 +1,82 @@ +import Browser from 'mobiledoc-kit/utils/browser'; +import Component from '@ember/component'; +import layout from '../templates/components/koenig-card-email'; +import {isBlank} from '@ember/utils'; +import {run} from '@ember/runloop'; +import {set} from '@ember/object'; + +export default Component.extend({ + layout, + + // attrs + payload: null, + isSelected: false, + isEditing: false, + + // closure actions + selectCard() {}, + deselectCard() {}, + editCard() {}, + saveCard() {}, + deleteCard() {}, + moveCursorToNextSection() {}, + moveCursorToPrevSection() {}, + addParagraphAfterCard() {}, + registerComponent() {}, + + init() { + this._super(...arguments); + this.registerComponent(this); + }, + + actions: { + updateHtml(html) { + console.log('updateHtml', html); + this._updatePayloadAttr('html', html); + }, + + registerEditor(textReplacementEditor) { + let commands = { + 'META+ENTER': run.bind(this, this._enter, 'meta'), + 'CTRL+ENTER': run.bind(this, this._enter, 'ctrl') + }; + + Object.keys(commands).forEach((str) => { + textReplacementEditor.registerKeyCommand({ + str, + run() { + return commands[str](textReplacementEditor, str); + } + }); + }); + + this._textReplacementEditor = textReplacementEditor; + }, + + leaveEditMode() { + if (isBlank(this.payload.html)) { + // afterRender is required to avoid double modification of `isSelected` + // TODO: see if there's a way to avoid afterRender + run.scheduleOnce('afterRender', this, this.deleteCard); + } + } + }, + + _updatePayloadAttr(attr, value) { + let payload = this.payload; + let save = this.saveCard; + + set(payload, attr, value); + + // update the mobiledoc and stay in edit mode + save(payload, false); + }, + + /* key commands ----------------------------------------------------------*/ + + _enter(modifier) { + if (this.isEditing && (modifier === 'meta' || (modifier === 'crtl' && Browser.isWin()))) { + this.editCard(); + } + } +}); diff --git a/ghost/admin/lib/koenig-editor/addon/components/koenig-menu-content.js b/ghost/admin/lib/koenig-editor/addon/components/koenig-menu-content.js index ed1cdac781..c8f659579b 100644 --- a/ghost/admin/lib/koenig-editor/addon/components/koenig-menu-content.js +++ b/ghost/admin/lib/koenig-editor/addon/components/koenig-menu-content.js @@ -1,9 +1,11 @@ import Component from '@ember/component'; import layout from '../templates/components/koenig-menu-content'; +import {inject as service} from '@ember/service'; export default Component.extend({ - layout, + config: service(), + layout, tagName: '', itemSections: null, diff --git a/ghost/admin/lib/koenig-editor/addon/components/koenig-text-replacement-html-input.js b/ghost/admin/lib/koenig-editor/addon/components/koenig-text-replacement-html-input.js new file mode 100644 index 0000000000..7face184d8 --- /dev/null +++ b/ghost/admin/lib/koenig-editor/addon/components/koenig-text-replacement-html-input.js @@ -0,0 +1,405 @@ +import Component from '@ember/component'; +import Editor from 'mobiledoc-kit/editor/editor'; +import cleanTextReplacementHtml from '../lib/clean-text-replacement-html'; +import defaultAtoms from '../options/atoms'; +import layout from '../templates/components/koenig-text-replacement-html-input'; +import registerKeyCommands, {TEXT_REPLACEMENT_KEY_COMMANDS} from '../options/key-commands'; +import validator from 'validator'; +import {DRAG_DISABLED_DATA_ATTR} from '../lib/dnd/constants'; +import {arrayToMap, toggleSpecialFormatEditState} from './koenig-editor'; +import {assign} from '@ember/polyfills'; +import {computed} from '@ember/object'; +import {getContentFromPasteEvent} from 'mobiledoc-kit/utils/parse-utils'; +import {getLinkMarkupFromRange} from '../utils/markup-utils'; +import {registerTextReplacementTextExpansions} from '../options/text-expansions'; +import {run} from '@ember/runloop'; + +// TODO: extract core to share functionality between this and `{{koenig-editor}}` + +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: '0.3.1', + markups: [], + atoms: [], + cards: [], + sections: [ + [1, 'p', [ + [0, [], 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: '{', // this is different because we use to represent {} replacements + SUP: '^', + SUB: '~' +}; + +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() {}, + onFocus() {}, + onBlur() {}, + + /* computed properties -------------------------------------------------- */ + + cleanHTML: computed('html', function () { + return cleanTextReplacementHtml(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 || []; + + // add our default atoms and cards, we want the defaults to be first so + // that they can be overridden by any passed-in atoms or cards. + // Use Array.concat to avoid modifying any passed in array references + atoms = defaultAtoms.concat(atoms); + + return assign({ + html: `

${this.cleanHTML || ''}

`, + 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 = []; + + editor = new Editor(editorOptions); + + registerKeyCommands(editor, this, TEXT_REPLACEMENT_KEY_COMMANDS); + registerTextReplacementTextExpansions(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.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); + }, + + didInsertElement() { + this._super(...arguments); + let editorElement = this.element.querySelector('[data-kg="editor"]'); + + this._pasteHandler = run.bind(this, this.handlePaste); + editorElement.addEventListener('paste', this._pasteHandler); + + this.element.dataset[DRAG_DISABLED_DATA_ATTR] = 'true'; + }, + + // 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); + + let editorElement = this.element.querySelector('[data-kg="editor"]'); + editorElement.removeEventListener('paste', this._pasteHandler); + + 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 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); + } + }, + + /* custom event handlers ------------------------------------------------ */ + + handlePaste(event) { + let {editor, editor: {range}} = this; + let {text} = getContentFromPasteEvent(event); + + if (!editor.cursor.isAddressable(event.target)) { + return; + } + + if (text && validator.isURL(text)) { + // if we have a text selection, make that selection a link + if (range && !range.isCollapsed && range.headSection === range.tailSection && range.headSection.isMarkerable) { + let linkMarkup = editor.builder.createMarkup('a', {href: text}); + editor.run((postEditor) => { + postEditor.addMarkupToRange(range, linkMarkup); + }); + editor.selectRange(range.tail); + + // prevent mobiledoc's default paste event handler firing + event.preventDefault(); + event.stopImmediatePropagation(); + return; + } + } + }, + + /* mobiledoc event handlers ----------------------------------------------*/ + + // 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/non-list 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) { + return cleanTextReplacementHtml(this.editor.element.innerHTML); + } + } +}); diff --git a/ghost/admin/lib/koenig-editor/addon/lib/clean-text-replacement-html.js b/ghost/admin/lib/koenig-editor/addon/lib/clean-text-replacement-html.js new file mode 100644 index 0000000000..0f911d6104 --- /dev/null +++ b/ghost/admin/lib/koenig-editor/addon/lib/clean-text-replacement-html.js @@ -0,0 +1,44 @@ +export default function cleanTextReplacementHtml(html = '', _options = {}) { + const defaults = {}; + const options = Object.assign({}, defaults, _options); + + if (!options.createDocument) { + const Parser = (typeof DOMParser !== 'undefined' && DOMParser) || (typeof window !== 'undefined' && window.DOMParser); + + if (!Parser) { + throw new Error('cleanTextReplacementHtml() must be passed a `createDocument` function as an option when used in a non-browser environment'); + } + + options.createDocument = function (html) { + const parser = new Parser(); + return parser.parseFromString(html, 'text/html'); + }; + } + + let cleanHtml = html + .replace(/(\s| ){2,}/g, ' ') + .trim() + .replace(/^ | $/g, '') + .trim(); + + // remove any elements that have a blank textContent + if (cleanHtml) { + let doc = options.createDocument(cleanHtml); + + doc.body.querySelectorAll('*').forEach((element) => { + if (!element.textContent.trim()) { + if (element.textContent.length > 0) { + // keep a single space to avoid collapsing spaces + let space = doc.createTextNode(' '); + element.replaceWith(space); + } else { + element.remove(); + } + } + }); + + cleanHtml = doc.body.innerHTML.trim(); + } + + return cleanHtml; +} diff --git a/ghost/admin/lib/koenig-editor/addon/options/cards.js b/ghost/admin/lib/koenig-editor/addon/options/cards.js index 7f2e5bc7fe..e0f9f139ac 100644 --- a/ghost/admin/lib/koenig-editor/addon/options/cards.js +++ b/ghost/admin/lib/koenig-editor/addon/options/cards.js @@ -10,7 +10,8 @@ export const CARD_COMPONENT_MAP = { code: 'koenig-card-code', embed: 'koenig-card-embed', bookmark: 'koenig-card-bookmark', - gallery: 'koenig-card-gallery' + gallery: 'koenig-card-gallery', + email: 'koenig-card-email' }; // map card names to generic icons (used for ghost elements when dragging) @@ -23,7 +24,8 @@ export const CARD_ICON_MAP = { code: 'koenig/kg-card-type-gen-embed', embed: 'koenig/kg-card-type-gen-embed', bookmark: 'koenig/kg-card-type-bookmark', - gallery: 'koenig/kg-card-type-gallery' + gallery: 'koenig/kg-card-type-gallery', + email: 'koenig/kg-card-type-gen-embed' }; // TODO: move koenigOptions directly into cards now that card components register @@ -39,7 +41,8 @@ export default [ return card.payload.imageSelector && !card.payload.src; }}), createComponentCard('markdown', {deleteIfEmpty: 'payload.markdown'}), - createComponentCard('gallery', {hasEditMode: false}) + createComponentCard('gallery', {hasEditMode: false}), + createComponentCard('email', {deleteIfEmpty: 'payload.html'}) ]; export const CARD_MENU = [ @@ -96,6 +99,14 @@ export const CARD_MENU = [ type: 'card', replaceArg: 'bookmark', params: ['url'] + }, + { + label: 'Email', + icon: 'koenig/kg-card-type-html', + matches: ['email'], + type: 'card', + replaceArg: 'email', + developerExperiment: true }] }, { diff --git a/ghost/admin/lib/koenig-editor/addon/options/key-commands.js b/ghost/admin/lib/koenig-editor/addon/options/key-commands.js index f86a213fe6..e473f08f7d 100644 --- a/ghost/admin/lib/koenig-editor/addon/options/key-commands.js +++ b/ghost/admin/lib/koenig-editor/addon/options/key-commands.js @@ -419,6 +419,19 @@ export const BASIC_KEY_COMMANDS = DEFAULT_KEY_COMMANDS.filter((command) => { return basicCommands.includes(command.str); }); +// key commands that are used in koenig-text-replacement-html-input +export const TEXT_REPLACEMENT_KEY_COMMANDS = DEFAULT_KEY_COMMANDS.filter((command) => { + let commands = [ + 'BACKSPACE', + 'CTRL+K', + 'META+K', + 'CTRL+ALT+U', + 'ENTER', + 'SHIFT+ENTER' + ]; + return commands.includes(command.str); +}); + export default function registerKeyCommands(editor, koenig, commands = DEFAULT_KEY_COMMANDS) { commands.forEach((keyCommand) => { editor.registerKeyCommand({ diff --git a/ghost/admin/lib/koenig-editor/addon/options/text-expansions.js b/ghost/admin/lib/koenig-editor/addon/options/text-expansions.js index f42de30588..d5f172efea 100644 --- a/ghost/admin/lib/koenig-editor/addon/options/text-expansions.js +++ b/ghost/admin/lib/koenig-editor/addon/options/text-expansions.js @@ -53,9 +53,171 @@ export function replaceWithListSection(editor, matches, listTagName) { }); } -function registerInlineMarkdownTextExpansions(editor) { - /* inline markdown ------------------------------------------------------ */ +function _addMarkdownMarkup(_this, editor, matches, markupStr) { + let {range} = editor; + let match = matches[0].trim(); + let mdChars = (match.length - matches[1].length) / 2; + range = range.extend(-(match.length)); + + editor.run((postEditor) => { + let startPos = postEditor.deleteRange(range.head.toRange().extend(mdChars)); + let textRange = startPos.toRange().extend(matches[1].length); + let markup = editor.builder.createMarkup(markupStr); + postEditor.addMarkupToRange(textRange, markup); + let endPos = postEditor.deleteRange(textRange.tail.toRange().extend(mdChars)); + postEditor.setRange(endPos.toRange()); + }); + + // must be scheduled so that the toggle isn't reset automatically + // by mobiledoc-kit re-setting state after the range is updated + run.later(_this, function () { + editor.toggleMarkup(markupStr); + }, 10); +} + +function _matchStrongStar(editor, text) { + let matches = text.match(/(?:^|\s)\*\*([^\s*]+|[^\s*][^*]*[^\s])\*\*$/); + if (matches) { + _addMarkdownMarkup(this, editor, matches, 'strong'); + } +} + +function _matchStrongUnderscore(editor, text) { + let matches = text.match(/(?:^|\s)__([^\s_]+|[^\s_][^_]*[^\s])__$/); + if (matches) { + _addMarkdownMarkup(this, editor, matches, 'strong'); + } +} + +function _matchEmStar(editor, text) { + // (?:^|\s) - match beginning of input or a starting space (don't capture) + // \* - match leading * + // ( - start capturing group + // [^\s*]+ - match a stretch with no spaces or * chars + // | - OR + // [^\s*] - match a single non-space or * char | this group will only match at + // [^*]* - match zero or more non * chars | least two chars so we need the + // [^\s] - match a single non-space char | [^\s*]+ to match single chars + // ) - end capturing group + // \* - match trailing * + // + // input = " *foo*" + // matches[0] = " *foo*" + // matches[1] = "foo" + let matches = text.match(/(?:^|\s)\*([^\s*]+|[^\s*][^*]*[^\s])\*$/); + if (matches) { + _addMarkdownMarkup(this, editor, matches, 'em'); + } +} + +function _matchEmUnderscore(editor, text) { + let matches = text.match(/(?:^|\s)_([^\s_]+|[^\s_][^_]*[^\s])_$/); + if (matches) { + _addMarkdownMarkup(this, editor, matches, 'em'); + } +} + +function _matchSub(editor, text) { + let matches = text.match(/(^|[^~])~([^\s~]+|[^\s~][^~]*[^\s~])~$/); + if (matches) { + // re-adjust the matches to remove the first matched char if it + // exists, otherwise our length calculations are off. This is + // different to other matchers because we match any char at the + // beginning rather than a blank space and need to allow ~~ for + // the strikethrough expansion + let newMatches = [ + matches[1] ? matches[0].replace(matches[1], '').trim() : matches[0], + matches[2] + ]; + _addMarkdownMarkup(this, editor, newMatches, 'sub'); + } +} + +function _matchStrikethrough(editor, text) { + let matches = text.match(/(?:^|\s)~~([^\s~]+|[^\s~][^~]*[^\s])~~$/); + if (matches) { + _addMarkdownMarkup(this, editor, matches, 's'); + } +} + +function _matchCode(editor, text) { + let matches = text.match(/(?:^|\s)`([^\s`]+|[^\s`][^`]*[^\s`])`$/); + if (matches) { + _addMarkdownMarkup(this, editor, matches, 'code'); + } +} + +function _matchSup(editor, text) { + let matches = text.match(/\^([^\s^]+|[^\s^][^^]*[^\s^])\^$/); + if (matches) { + _addMarkdownMarkup(this, editor, matches, 'sup'); + } +} + +function _matchLink(editor, text) { + let {range} = editor; + let matches = text.match(/(?:^|\s)\[([^\s\]]*|[^\s\]][^\]]*[^\s\]])\]\(([^\s)]+|[^\s)][^)]*[^\s)])\)/); + if (matches) { + let url = matches[2]; + let text = matches[1] || url; + let hasText = !!matches[1]; + let match = matches[0].trim(); + range = range.extend(-match.length); + + editor.run((postEditor) => { + let startPos = postEditor.deleteRange(range.head.toRange().extend(hasText ? 1 : 3)); + let textRange = startPos.toRange().extend(text.length); + let a = postEditor.builder.createMarkup('a', {href: url}); + postEditor.addMarkupToRange(textRange, a); + let remainingRange = textRange.tail.toRange().extend(hasText ? (matches[2] || url).length + 3 : 1); + let endPos = postEditor.deleteRange(remainingRange); + postEditor.setRange(endPos.toRange()); + }); + + // must be scheduled so that the toggle isn't reset automatically + run.schedule('actions', this, function () { + editor.toggleMarkup('a'); + }); + } +} + +function _matchImage(editor, text) { + let matches = text.match(/^!\[(.*?)\]\((.*?)\)$/); + if (matches) { + let {range: {head, head: {section}}} = editor; + let src = matches[2].trim(); + let alt = matches[1].trim(); + + // skip if cursor is not at end of section + if (!head.isTail()) { + return; + } + + // mobiledoc lists don't support cards + if (section.isListItem) { + return; + } + + editor.run((postEditor) => { + let card = postEditor.builder.createCardSection('image', {src, alt}); + // need to check the section before replacing else it will always + // add a trailing paragraph + let needsTrailingParagraph = !section.next; + + editor.range.extend(-(matches[0].length)); + postEditor.replaceSection(editor.range.headSection, card); + + if (needsTrailingParagraph) { + let newSection = editor.builder.createMarkupSection('p'); + postEditor.insertSectionAtEnd(newSection); + postEditor.setRange(newSection.tailPosition()); + } + }); + } +} + +function registerDashTextExpansions(editor) { // --\s = en dash – // ---. = em dash — // separate to the grouped replacement functions because we're matching on @@ -104,7 +266,9 @@ function registerInlineMarkdownTextExpansions(editor) { } } }); +} +function registerInlineMarkdownTextExpansions(editor) { // We don't want to run all our content rules on every text entry event, // instead we check to see if this text entry event could match a content // rule, and only then run the rules. Right now we only want to match @@ -119,194 +283,30 @@ function registerInlineMarkdownTextExpansions(editor) { switch (matches[0]) { case '*': - matchStrongStar(editor, text); - matchEmStar(editor, text); + _matchStrongStar(editor, text); + _matchEmStar(editor, text); break; case '_': - matchStrongUnderscore(editor, text); - matchEmUnderscore(editor, text); + _matchStrongUnderscore(editor, text); + _matchEmUnderscore(editor, text); break; case ')': - matchLink(editor, text); - matchImage(editor, text); + _matchLink(editor, text); + _matchImage(editor, text); break; case '~': - matchSub(editor, text); - matchStrikethrough(editor, text); + _matchSub(editor, text); + _matchStrikethrough(editor, text); break; case '`': - matchCode(editor, text); + _matchCode(editor, text); break; case '^': - matchSup(editor, text); + _matchSup(editor, text); break; } } }); - - function _addMarkdownMarkup(_this, editor, matches, markupStr) { - let {range} = editor; - let match = matches[0].trim(); - let mdChars = (match.length - matches[1].length) / 2; - - range = range.extend(-(match.length)); - - editor.run((postEditor) => { - let startPos = postEditor.deleteRange(range.head.toRange().extend(mdChars)); - let textRange = startPos.toRange().extend(matches[1].length); - let markup = editor.builder.createMarkup(markupStr); - postEditor.addMarkupToRange(textRange, markup); - let endPos = postEditor.deleteRange(textRange.tail.toRange().extend(mdChars)); - postEditor.setRange(endPos.toRange()); - }); - - // must be scheduled so that the toggle isn't reset automatically - // by mobiledoc-kit re-setting state after the range is updated - run.later(_this, function () { - editor.toggleMarkup(markupStr); - }, 10); - } - - function matchStrongStar(editor, text) { - let matches = text.match(/(?:^|\s)\*\*([^\s*]+|[^\s*][^*]*[^\s])\*\*$/); - if (matches) { - _addMarkdownMarkup(this, editor, matches, 'strong'); - } - } - - function matchStrongUnderscore(editor, text) { - let matches = text.match(/(?:^|\s)__([^\s_]+|[^\s_][^_]*[^\s])__$/); - if (matches) { - _addMarkdownMarkup(this, editor, matches, 'strong'); - } - } - - function matchEmStar(editor, text) { - // (?:^|\s) - match beginning of input or a starting space (don't capture) - // \* - match leading * - // ( - start capturing group - // [^\s*]+ - match a stretch with no spaces or * chars - // | - OR - // [^\s*] - match a single non-space or * char | this group will only match at - // [^*]* - match zero or more non * chars | least two chars so we need the - // [^\s] - match a single non-space char | [^\s*]+ to match single chars - // ) - end capturing group - // \* - match trailing * - // - // input = " *foo*" - // matches[0] = " *foo*" - // matches[1] = "foo" - let matches = text.match(/(?:^|\s)\*([^\s*]+|[^\s*][^*]*[^\s])\*$/); - if (matches) { - _addMarkdownMarkup(this, editor, matches, 'em'); - } - } - - function matchEmUnderscore(editor, text) { - let matches = text.match(/(?:^|\s)_([^\s_]+|[^\s_][^_]*[^\s])_$/); - if (matches) { - _addMarkdownMarkup(this, editor, matches, 'em'); - } - } - - function matchSub(editor, text) { - let matches = text.match(/(^|[^~])~([^\s~]+|[^\s~][^~]*[^\s~])~$/); - if (matches) { - // re-adjust the matches to remove the first matched char if it - // exists, otherwise our length calculations are off. This is - // different to other matchers because we match any char at the - // beginning rather than a blank space and need to allow ~~ for - // the strikethrough expansion - let newMatches = [ - matches[1] ? matches[0].replace(matches[1], '').trim() : matches[0], - matches[2] - ]; - _addMarkdownMarkup(this, editor, newMatches, 'sub'); - } - } - - function matchStrikethrough(editor, text) { - let matches = text.match(/(?:^|\s)~~([^\s~]+|[^\s~][^~]*[^\s])~~$/); - if (matches) { - _addMarkdownMarkup(this, editor, matches, 's'); - } - } - - function matchCode(editor, text) { - let matches = text.match(/(?:^|\s)`([^\s`]+|[^\s`][^`]*[^\s`])`$/); - if (matches) { - _addMarkdownMarkup(this, editor, matches, 'code'); - } - } - - function matchSup(editor, text) { - let matches = text.match(/\^([^\s^]+|[^\s^][^^]*[^\s^])\^$/); - if (matches) { - _addMarkdownMarkup(this, editor, matches, 'sup'); - } - } - - function matchLink(editor, text) { - let {range} = editor; - let matches = text.match(/(?:^|\s)\[([^\s\]]*|[^\s\]][^\]]*[^\s\]])\]\(([^\s)]+|[^\s)][^)]*[^\s)])\)/); - if (matches) { - let url = matches[2]; - let text = matches[1] || url; - let hasText = !!matches[1]; - let match = matches[0].trim(); - range = range.extend(-match.length); - - editor.run((postEditor) => { - let startPos = postEditor.deleteRange(range.head.toRange().extend(hasText ? 1 : 3)); - let textRange = startPos.toRange().extend(text.length); - let a = postEditor.builder.createMarkup('a', {href: url}); - postEditor.addMarkupToRange(textRange, a); - let remainingRange = textRange.tail.toRange().extend(hasText ? (matches[2] || url).length + 3 : 1); - let endPos = postEditor.deleteRange(remainingRange); - postEditor.setRange(endPos.toRange()); - }); - - // must be scheduled so that the toggle isn't reset automatically - run.schedule('actions', this, function () { - editor.toggleMarkup('a'); - }); - } - } - - function matchImage(editor, text) { - let matches = text.match(/^!\[(.*?)\]\((.*?)\)$/); - if (matches) { - let {range: {head, head: {section}}} = editor; - let src = matches[2].trim(); - let alt = matches[1].trim(); - - // skip if cursor is not at end of section - if (!head.isTail()) { - return; - } - - // mobiledoc lists don't support cards - if (section.isListItem) { - return; - } - - editor.run((postEditor) => { - let card = postEditor.builder.createCardSection('image', {src, alt}); - // need to check the section before replacing else it will always - // add a trailing paragraph - let needsTrailingParagraph = !section.next; - - editor.range.extend(-(matches[0].length)); - postEditor.replaceSection(editor.range.headSection, card); - - if (needsTrailingParagraph) { - let newSection = editor.builder.createMarkupSection('p'); - postEditor.insertSectionAtEnd(newSection); - postEditor.setRange(newSection.tailPosition()); - } - }); - } - } } export default function (editor, koenig) { @@ -435,6 +435,7 @@ export default function (editor, koenig) { // must come after block expansions so that the smart hyphens expansion // doesn't break the divider card expansion + registerDashTextExpansions(editor); registerInlineMarkdownTextExpansions(editor); } @@ -445,5 +446,96 @@ export function registerBasicTextExpansions(editor) { editor.unregisterTextInputHandler('ul'); editor.unregisterTextInputHandler('ol'); + registerDashTextExpansions(editor); registerInlineMarkdownTextExpansions(editor); } + +// TODO: reduce duplication +export function registerTextReplacementTextExpansions(editor, koenig) { + // unregister mobiledoc-kit's block-level text handlers + editor.unregisterTextInputHandler('heading'); + + 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); + }); + } + } + }); + + // as per registerInlineMarkdownTextExpansions but without ` for code and image matches + editor.onTextInput({ + name: 'inline_markdown', + match: /[*_)~^]$/, + run(editor, matches) { + let text = editor.range.head.section.textUntil(editor.range.head); + + switch (matches[0]) { + case '*': + _matchStrongStar(editor, text); + _matchEmStar(editor, text); + break; + case '_': + _matchStrongUnderscore(editor, text); + _matchEmUnderscore(editor, text); + break; + case ')': + _matchLink(editor, text); + break; + case '~': + _matchSub(editor, text); + _matchStrikethrough(editor, text); + break; + case '^': + _matchSup(editor, text); + break; + } + } + }); + + editor.onTextInput({ + name: 'text_replacement', + match: /\}$/, + run(editor) { + let text = editor.range.head.section.textUntil(editor.range.head); + + let match = text.match(/(?:^|\s)\{([^\s{}]+|[^\s{}][^{}]*[^\s{}])\}$/); + if (match) { + _addMarkdownMarkup(this, editor, match, 'code'); + } + } + }); +} diff --git a/ghost/admin/lib/koenig-editor/addon/templates/components/koenig-card-email.hbs b/ghost/admin/lib/koenig-editor/addon/templates/components/koenig-card-email.hbs new file mode 100644 index 0000000000..46ff7958da --- /dev/null +++ b/ghost/admin/lib/koenig-editor/addon/templates/components/koenig-card-email.hbs @@ -0,0 +1,36 @@ + + {{#if this.isEditing}} + + {{else}} +

{{{this.payload.html}}}

+
+ {{/if}} +
diff --git a/ghost/admin/lib/koenig-editor/addon/templates/components/koenig-menu-content.hbs b/ghost/admin/lib/koenig-editor/addon/templates/components/koenig-menu-content.hbs index afe39f31d6..ac73d4b9e9 100644 --- a/ghost/admin/lib/koenig-editor/addon/templates/components/koenig-menu-content.hbs +++ b/ghost/admin/lib/koenig-editor/addon/templates/components/koenig-menu-content.hbs @@ -3,9 +3,11 @@ {{section.title}} {{#each section.items as |item|}} -
-
{{svg-jar item.icon class="w8 h8"}}
-
{{item.label}}
-
+ {{#if (or (not item.developerExperiment) (and item.developerExperiment config.enableDeveloperExperiments))}} +
+
{{svg-jar item.icon class="w8 h8"}}
+
{{item.label}}
+
+ {{/if}} {{/each}} {{/each}} diff --git a/ghost/admin/lib/koenig-editor/addon/templates/components/koenig-text-replacement-html-input.hbs b/ghost/admin/lib/koenig-editor/addon/templates/components/koenig-text-replacement-html-input.hbs new file mode 100644 index 0000000000..326a854497 --- /dev/null +++ b/ghost/admin/lib/koenig-editor/addon/templates/components/koenig-text-replacement-html-input.hbs @@ -0,0 +1,37 @@ +
+
+
+ + + +{{!-- pop-up link hover toolbar --}} + + +{{!-- pop-up link editing toolbar --}} +{{#if this.linkRange}} + +{{/if}} \ No newline at end of file diff --git a/ghost/admin/lib/koenig-editor/app/components/koenig-card-email.js b/ghost/admin/lib/koenig-editor/app/components/koenig-card-email.js new file mode 100644 index 0000000000..87ac7963a7 --- /dev/null +++ b/ghost/admin/lib/koenig-editor/app/components/koenig-card-email.js @@ -0,0 +1 @@ +export {default} from 'koenig-editor/components/koenig-card-email'; diff --git a/ghost/admin/lib/koenig-editor/app/components/koenig-text-replacement-html-input.js b/ghost/admin/lib/koenig-editor/app/components/koenig-text-replacement-html-input.js new file mode 100644 index 0000000000..206ecdfbd9 --- /dev/null +++ b/ghost/admin/lib/koenig-editor/app/components/koenig-text-replacement-html-input.js @@ -0,0 +1 @@ +export {default} from 'koenig-editor/components/koenig-text-replacement-html-input';