diff --git a/app/components/gh-koenig-editor.hbs b/app/components/gh-koenig-editor.hbs index df377e2b1..9fe784fe6 100644 --- a/app/components/gh-koenig-editor.hbs +++ b/app/components/gh-koenig-editor.hbs @@ -30,5 +30,7 @@ @scrollOffsetTopSelector={{this.scrollOffsetTopSelector}} @scrollOffsetBottomSelector={{this.scrollOffsetBottomSelector}} @wordCountDidChange={{action this.onWordCountChange}} + @snippets={{@snippets}} + @saveSnippet={{@saveSnippet}} /> \ No newline at end of file diff --git a/app/controllers/editor.js b/app/controllers/editor.js index d4caee5d4..05ab7fe64 100644 --- a/app/controllers/editor.js +++ b/app/controllers/editor.js @@ -138,6 +138,14 @@ export default Controller.extend({ } }), + _snippets: computed(function () { + return this.store.peekAll('snippet'); + }), + + snippets: computed('_snippets.@each.isNew', function () { + return this._snippets.reject(snippet => snippet.get('isNew')); + }), + _autosaveRunning: computed('_autosave.isRunning', '_timedSave.isRunning', function () { let autosave = this.get('_autosave.isRunning'); let timedsave = this.get('_timedSave.isRunning'); @@ -281,6 +289,27 @@ export default Controller.extend({ updateWordCount(counts) { this.set('wordCount', counts); + }, + + saveSnippet(snippet) { + let snippetRecord = this.store.createRecord('snippet', snippet); + return snippetRecord.save().then(() => { + this.notifications.closeAlerts('snippet.save'); + this.notifications.showNotification( + `Snippet saved as "${snippet.title}"`, + {type: 'success'} + ); + return snippetRecord; + }).catch((error) => { + if (!snippetRecord.errors.isEmpty) { + this.notifications.showAlert( + `Snippet save failed: ${snippetRecord.errors.messages.join('. ')}`, + {type: 'error', key: 'snippet.save'} + ); + } + snippetRecord.rollbackAttributes(); + throw error; + }); } }, diff --git a/app/controllers/posts.js b/app/controllers/posts.js index d3613111f..d816216e8 100644 --- a/app/controllers/posts.js +++ b/app/controllers/posts.js @@ -56,6 +56,7 @@ export default Controller.extend({ _hasLoadedTags: false, _hasLoadedAuthors: false, + _hasLoadedSnippets: false, availableTypes: null, availableVisibilities: null, @@ -133,6 +134,10 @@ export default Controller.extend({ return authors.findBy('slug', author) || {slug: '!unknown'}; }), + snippets: computed(function () { + return this.store.peekAll('snippet'); + }), + actions: { changeType(type) { this.set('type', get(type, 'value')); diff --git a/app/mixins/validation-engine.js b/app/mixins/validation-engine.js index b6082af78..0a074d2da 100644 --- a/app/mixins/validation-engine.js +++ b/app/mixins/validation-engine.js @@ -17,6 +17,7 @@ import SetupValidator from 'ghost-admin/validators/setup'; import SigninValidator from 'ghost-admin/validators/signin'; import SignupValidator from 'ghost-admin/validators/signup'; import SlackIntegrationValidator from 'ghost-admin/validators/slack-integration'; +import SnippetValidator from 'ghost-admin/validators/snippet'; import TagSettingsValidator from 'ghost-admin/validators/tag-settings'; import UserValidator from 'ghost-admin/validators/user'; import WebhookValidator from 'ghost-admin/validators/webhook'; @@ -52,7 +53,8 @@ export default Mixin.create({ member: MemberValidator, integration: IntegrationValidator, webhook: WebhookValidator, - label: LabelValidator + label: LabelValidator, + snippet: SnippetValidator }, // This adds the Errors object to the validation engine, and shouldn't affect diff --git a/app/models/snippet.js b/app/models/snippet.js new file mode 100644 index 000000000..6e3268b44 --- /dev/null +++ b/app/models/snippet.js @@ -0,0 +1,11 @@ +import Model, {attr} from '@ember-data/model'; +import ValidationEngine from 'ghost-admin/mixins/validation-engine'; + +export default Model.extend(ValidationEngine, { + validationType: 'snippet', + + title: attr('string'), + mobiledoc: attr('json-string'), + createdAtUTC: attr('moment-utc'), + updatedAtUTC: attr('moment-utc') +}); diff --git a/app/routes/posts.js b/app/routes/posts.js index d30d86fa0..40a439a54 100644 --- a/app/routes/posts.js +++ b/app/routes/posts.js @@ -79,7 +79,7 @@ export default AuthenticatedRoute.extend({ }); }, - // trigger a background load of all tags and authors for use in the filter dropdowns + // trigger a background load of all tags, authors, and snipps for use in filter dropdowns and card menu setupController(controller) { this._super(...arguments); @@ -96,6 +96,12 @@ export default AuthenticatedRoute.extend({ }); } }); + + if (!controller._hasLoadedSnippets) { + this.store.query('snippet', {limit: 'all'}).then(() => { + controller._hasLoadedSnippets = true; + }); + } }, actions: { diff --git a/app/templates/editor.hbs b/app/templates/editor.hbs index 18b03993e..d04fb7150 100644 --- a/app/templates/editor.hbs +++ b/app/templates/editor.hbs @@ -81,6 +81,8 @@ @scrollOffsetBottomSelector=".gh-mobile-nav-bar" @onEditorCreated={{action "setKoenigEditor"}} @onWordCountChange={{action "updateWordCount"}} + @snippets={{this.snippets}} + @saveSnippet={{action "saveSnippet"}} />
diff --git a/app/validators/snippet.js b/app/validators/snippet.js new file mode 100644 index 000000000..d64d38dfb --- /dev/null +++ b/app/validators/snippet.js @@ -0,0 +1,22 @@ +import BaseValidator from './base'; +import {isBlank} from '@ember/utils'; + +export default BaseValidator.create({ + properties: ['title', 'mobiledoc'], + + title(model) { + if (isBlank(model.get('title'))) { + model.errors.add('title', 'Title cannot be blank'); + this.invalidate(); + } + model.get('hasValidated').addObject('title'); + }, + + mobiledoc(model) { + if (isBlank(model.get('mobiledoc'))) { + model.errors.add('mobiledoc', 'Content cannot be blank.'); + this.invalidate(); + } + model.get('hasValidated').addObject('mobiledoc'); + } +}); diff --git a/config/environment.js b/config/environment.js index 4214fbce5..550d6ac56 100644 --- a/config/environment.js +++ b/config/environment.js @@ -47,7 +47,7 @@ module.exports = function (environment) { // Enable mirage here in order to mock API endpoints during development ENV['ember-cli-mirage'] = { - enabled: false + enabled: true }; } diff --git a/lib/koenig-editor/addon/components/koenig-editor.hbs b/lib/koenig-editor/addon/components/koenig-editor.hbs index b66f2e571..15476cebe 100644 --- a/lib/koenig-editor/addon/components/koenig-editor.hbs +++ b/lib/koenig-editor/addon/components/koenig-editor.hbs @@ -12,6 +12,7 @@ @toggleSection={{action "toggleSection"}} @toggleHeaderSection={{action "toggleHeaderSection"}} @editLink={{action "editLink"}} + @addSnippet={{this.addSnippet}} /> {{!-- pop-up link hover toolbar --}} @@ -34,18 +35,32 @@ /> {{/if}} +{{!-- pop-up snippet editing toolbar --}} +{{#if this.snippetRange}} + +{{/if}} + {{!-- (+) icon and pop-up menu --}} {{!-- slash menu popup --}} {{!-- all component cards wormholed into the editor canvas --}} diff --git a/lib/koenig-editor/addon/components/koenig-editor.js b/lib/koenig-editor/addon/components/koenig-editor.js index cec7a6184..009bc44f0 100644 --- a/lib/koenig-editor/addon/components/koenig-editor.js +++ b/lib/koenig-editor/addon/components/koenig-editor.js @@ -17,6 +17,7 @@ import registerKeyCommands from '../options/key-commands'; import registerTextExpansions from '../options/text-expansions'; import validator from 'validator'; import {A} from '@ember/array'; +import {action} from '@ember/object'; import {assign} from '@ember/polyfills'; import {camelize, capitalize} from '@ember/string'; import {createParserPlugins} from '@tryghost/kg-parser-plugins'; @@ -565,6 +566,23 @@ export default Component.extend({ }); }, + replaceWithPost(range, post) { + let {editor} = this; + let {head: {section}} = range; + + editor.run((postEditor) => { + let nextPosition = postEditor.deleteRange(section.toRange()); + postEditor.setRange(nextPosition); + + let blankSection = postEditor.builder.createMarkupSection('p'); + postEditor.insertSectionBefore(editor.post.sections, blankSection); + postEditor.setRange(blankSection.toRange()); + + nextPosition = postEditor.insertPost(editor.range.head, post); + postEditor.setRange(nextPosition); + }); + }, + selectCard(card) { this.selectCard(card); }, @@ -641,6 +659,23 @@ export default Component.extend({ } }, + addSnippet: action(function (event) { + event.preventDefault(); + event.stopImmediatePropagation(); + + let {selectedRange} = this; + + if (selectedRange.isCollapsed) { + return; + } + + this.set('snippetRange', selectedRange); + }), + + cancelAddSnippet: action(function () { + this.set('snippetRange', null); + }), + /* public interface ----------------------------------------------------- */ // TODO: find a better way to expose the public interface? diff --git a/lib/koenig-editor/addon/components/koenig-link-input.hbs b/lib/koenig-editor/addon/components/koenig-link-input.hbs index dad06b835..fe11d121a 100644 --- a/lib/koenig-editor/addon/components/koenig-link-input.hbs +++ b/lib/koenig-editor/addon/components/koenig-link-input.hbs @@ -1,8 +1,8 @@ diff --git a/lib/koenig-editor/addon/components/koenig-link-input.js b/lib/koenig-editor/addon/components/koenig-link-input.js index fc67b4fdd..033d46f37 100644 --- a/lib/koenig-editor/addon/components/koenig-link-input.js +++ b/lib/koenig-editor/addon/components/koenig-link-input.js @@ -1,4 +1,5 @@ import Component from '@ember/component'; +import getScrollParent from '../utils/get-scroll-parent'; import relativeToAbsolute from '../lib/relative-to-absolute'; import {TOOLBAR_MARGIN} from './koenig-toolbar'; import {computed} from '@ember/object'; @@ -11,21 +12,6 @@ import {inject as service} from '@ember/service'; // TODO: handle via CSS? const TICK_ADJUSTMENT = 8; -// TODO: move to a util -function getScrollParent(node) { - const isElement = node instanceof HTMLElement; - const overflowY = isElement && window.getComputedStyle(node).overflowY; - const isScrollable = overflowY !== 'visible' && overflowY !== 'hidden'; - - if (!node) { - return null; - } else if (isScrollable && node.scrollHeight >= node.clientHeight) { - return node; - } - - return getScrollParent(node.parentNode) || document.body; -} - export default Component.extend({ config: service(), @@ -194,7 +180,7 @@ export default Component.extend({ scrollParent.scrollTop = scrollTop; }, - // TODO: largely shared with {{koenig-toolbar}} code - extract to a shared util? + // TODO: largely shared with {{koenig-toolbar}} and {{koenig-snippet-input}} - extract to a shared util? _positionToolbar() { let containerRect = this.element.offsetParent.getBoundingClientRect(); let rangeRect = this.linkRect || this._windowRange.getBoundingClientRect(); diff --git a/lib/koenig-editor/addon/components/koenig-menu-content.hbs b/lib/koenig-editor/addon/components/koenig-menu-content.hbs index 37656e741..d6eba0958 100644 --- a/lib/koenig-editor/addon/components/koenig-menu-content.hbs +++ b/lib/koenig-editor/addon/components/koenig-menu-content.hbs @@ -1,13 +1,17 @@ -{{#each itemSections as |section sectionIndex|}} -
- {{section.title}} -
- {{#each section.items as |item|}} - {{#if (or (not item.developerExperiment) (and item.developerExperiment (enable-developer-experiments)))}} -