Implemented first iteration of content snippets
closes https://github.com/TryGhost/Team/issues/411 - adds "Create snippet" icon to the editor toolbar - uses the same link input component design for specifying snippet titles - snippets are loaded in the background when the editor is accessed - snippets are listed at the bottom of the card menus of the + and / menus - clicking a snippet inserts the snippet's contents in place of the current blank section
This commit is contained in:
parent
8a394aea8e
commit
5606b9c068
|
@ -30,5 +30,7 @@
|
||||||
@scrollOffsetTopSelector={{this.scrollOffsetTopSelector}}
|
@scrollOffsetTopSelector={{this.scrollOffsetTopSelector}}
|
||||||
@scrollOffsetBottomSelector={{this.scrollOffsetBottomSelector}}
|
@scrollOffsetBottomSelector={{this.scrollOffsetBottomSelector}}
|
||||||
@wordCountDidChange={{action this.onWordCountChange}}
|
@wordCountDidChange={{action this.onWordCountChange}}
|
||||||
|
@snippets={{@snippets}}
|
||||||
|
@saveSnippet={{@saveSnippet}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
|
@ -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 () {
|
_autosaveRunning: computed('_autosave.isRunning', '_timedSave.isRunning', function () {
|
||||||
let autosave = this.get('_autosave.isRunning');
|
let autosave = this.get('_autosave.isRunning');
|
||||||
let timedsave = this.get('_timedSave.isRunning');
|
let timedsave = this.get('_timedSave.isRunning');
|
||||||
|
@ -281,6 +289,27 @@ export default Controller.extend({
|
||||||
|
|
||||||
updateWordCount(counts) {
|
updateWordCount(counts) {
|
||||||
this.set('wordCount', 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;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -56,6 +56,7 @@ export default Controller.extend({
|
||||||
|
|
||||||
_hasLoadedTags: false,
|
_hasLoadedTags: false,
|
||||||
_hasLoadedAuthors: false,
|
_hasLoadedAuthors: false,
|
||||||
|
_hasLoadedSnippets: false,
|
||||||
|
|
||||||
availableTypes: null,
|
availableTypes: null,
|
||||||
availableVisibilities: null,
|
availableVisibilities: null,
|
||||||
|
@ -133,6 +134,10 @@ export default Controller.extend({
|
||||||
return authors.findBy('slug', author) || {slug: '!unknown'};
|
return authors.findBy('slug', author) || {slug: '!unknown'};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
snippets: computed(function () {
|
||||||
|
return this.store.peekAll('snippet');
|
||||||
|
}),
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
changeType(type) {
|
changeType(type) {
|
||||||
this.set('type', get(type, 'value'));
|
this.set('type', get(type, 'value'));
|
||||||
|
|
|
@ -17,6 +17,7 @@ import SetupValidator from 'ghost-admin/validators/setup';
|
||||||
import SigninValidator from 'ghost-admin/validators/signin';
|
import SigninValidator from 'ghost-admin/validators/signin';
|
||||||
import SignupValidator from 'ghost-admin/validators/signup';
|
import SignupValidator from 'ghost-admin/validators/signup';
|
||||||
import SlackIntegrationValidator from 'ghost-admin/validators/slack-integration';
|
import SlackIntegrationValidator from 'ghost-admin/validators/slack-integration';
|
||||||
|
import SnippetValidator from 'ghost-admin/validators/snippet';
|
||||||
import TagSettingsValidator from 'ghost-admin/validators/tag-settings';
|
import TagSettingsValidator from 'ghost-admin/validators/tag-settings';
|
||||||
import UserValidator from 'ghost-admin/validators/user';
|
import UserValidator from 'ghost-admin/validators/user';
|
||||||
import WebhookValidator from 'ghost-admin/validators/webhook';
|
import WebhookValidator from 'ghost-admin/validators/webhook';
|
||||||
|
@ -52,7 +53,8 @@ export default Mixin.create({
|
||||||
member: MemberValidator,
|
member: MemberValidator,
|
||||||
integration: IntegrationValidator,
|
integration: IntegrationValidator,
|
||||||
webhook: WebhookValidator,
|
webhook: WebhookValidator,
|
||||||
label: LabelValidator
|
label: LabelValidator,
|
||||||
|
snippet: SnippetValidator
|
||||||
},
|
},
|
||||||
|
|
||||||
// This adds the Errors object to the validation engine, and shouldn't affect
|
// This adds the Errors object to the validation engine, and shouldn't affect
|
||||||
|
|
|
@ -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')
|
||||||
|
});
|
|
@ -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) {
|
setupController(controller) {
|
||||||
this._super(...arguments);
|
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: {
|
actions: {
|
||||||
|
|
|
@ -81,6 +81,8 @@
|
||||||
@scrollOffsetBottomSelector=".gh-mobile-nav-bar"
|
@scrollOffsetBottomSelector=".gh-mobile-nav-bar"
|
||||||
@onEditorCreated={{action "setKoenigEditor"}}
|
@onEditorCreated={{action "setKoenigEditor"}}
|
||||||
@onWordCountChange={{action "updateWordCount"}}
|
@onWordCountChange={{action "updateWordCount"}}
|
||||||
|
@snippets={{this.snippets}}
|
||||||
|
@saveSnippet={{action "saveSnippet"}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="absolute flex items-center br3 bg-white {{if editor.headerClass "right-4 bottom-4" "right-6 bottom-6"}}">
|
<div class="absolute flex items-center br3 bg-white {{if editor.headerClass "right-4 bottom-4" "right-6 bottom-6"}}">
|
||||||
|
|
|
@ -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');
|
||||||
|
}
|
||||||
|
});
|
|
@ -47,7 +47,7 @@ module.exports = function (environment) {
|
||||||
|
|
||||||
// Enable mirage here in order to mock API endpoints during development
|
// Enable mirage here in order to mock API endpoints during development
|
||||||
ENV['ember-cli-mirage'] = {
|
ENV['ember-cli-mirage'] = {
|
||||||
enabled: false
|
enabled: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,6 +12,7 @@
|
||||||
@toggleSection={{action "toggleSection"}}
|
@toggleSection={{action "toggleSection"}}
|
||||||
@toggleHeaderSection={{action "toggleHeaderSection"}}
|
@toggleHeaderSection={{action "toggleHeaderSection"}}
|
||||||
@editLink={{action "editLink"}}
|
@editLink={{action "editLink"}}
|
||||||
|
@addSnippet={{this.addSnippet}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{{!-- pop-up link hover toolbar --}}
|
{{!-- pop-up link hover toolbar --}}
|
||||||
|
@ -34,18 +35,32 @@
|
||||||
/>
|
/>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
{{!-- pop-up snippet editing toolbar --}}
|
||||||
|
{{#if this.snippetRange}}
|
||||||
|
<KoenigSnippetInput
|
||||||
|
@editor={{this.editor}}
|
||||||
|
@snippetRange={{this.snippetRange}}
|
||||||
|
@save={{@saveSnippet}}
|
||||||
|
@cancel={{this.cancelAddSnippet}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
{{!-- (+) icon and pop-up menu --}}
|
{{!-- (+) icon and pop-up menu --}}
|
||||||
<KoenigPlusMenu
|
<KoenigPlusMenu
|
||||||
@editor={{this.editor}}
|
@editor={{this.editor}}
|
||||||
@editorRange={{this.selectedRange}}
|
@editorRange={{this.selectedRange}}
|
||||||
|
@snippets={{this.snippets}}
|
||||||
@replaceWithCardSection={{action "replaceWithCardSection"}}
|
@replaceWithCardSection={{action "replaceWithCardSection"}}
|
||||||
|
@replaceWithPost={{action "replaceWithPost"}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{{!-- slash menu popup --}}
|
{{!-- slash menu popup --}}
|
||||||
<KoenigSlashMenu
|
<KoenigSlashMenu
|
||||||
@editor={{this.editor}}
|
@editor={{this.editor}}
|
||||||
@editorRange={{this.selectedRange}}
|
@editorRange={{this.selectedRange}}
|
||||||
|
@snippets={{this.snippets}}
|
||||||
@replaceWithCardSection={{action "replaceWithCardSection"}}
|
@replaceWithCardSection={{action "replaceWithCardSection"}}
|
||||||
|
@replaceWithPost={{action "replaceWithPost"}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{{!-- all component cards wormholed into the editor canvas --}}
|
{{!-- all component cards wormholed into the editor canvas --}}
|
||||||
|
|
|
@ -17,6 +17,7 @@ import registerKeyCommands from '../options/key-commands';
|
||||||
import registerTextExpansions from '../options/text-expansions';
|
import registerTextExpansions from '../options/text-expansions';
|
||||||
import validator from 'validator';
|
import validator from 'validator';
|
||||||
import {A} from '@ember/array';
|
import {A} from '@ember/array';
|
||||||
|
import {action} from '@ember/object';
|
||||||
import {assign} from '@ember/polyfills';
|
import {assign} from '@ember/polyfills';
|
||||||
import {camelize, capitalize} from '@ember/string';
|
import {camelize, capitalize} from '@ember/string';
|
||||||
import {createParserPlugins} from '@tryghost/kg-parser-plugins';
|
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) {
|
selectCard(card) {
|
||||||
this.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 ----------------------------------------------------- */
|
/* public interface ----------------------------------------------------- */
|
||||||
// TODO: find a better way to expose the public interface?
|
// TODO: find a better way to expose the public interface?
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
<input
|
<input
|
||||||
placeholder="Enter url"
|
placeholder="Enter url"
|
||||||
value={{href}}
|
value={{this.href}}
|
||||||
class="kg-link-input pa2 pr6 mih-100 ba br3 shadow-2 f8 lh-heading tracked-2 outline-0 b--blue h10 nudge-top--8"
|
class="kg-link-input pa2 pr6 mih-100 ba br3 shadow-2 f8 lh-heading tracked-2 outline-0 b--blue h10 nudge-top--8"
|
||||||
oninput={{action (mut href) value="target.value"}}
|
oninput={{action (mut this.href) value="target.value"}}
|
||||||
onkeydown={{action "inputKeydown"}}
|
onkeydown={{action "inputKeydown"}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import Component from '@ember/component';
|
import Component from '@ember/component';
|
||||||
|
import getScrollParent from '../utils/get-scroll-parent';
|
||||||
import relativeToAbsolute from '../lib/relative-to-absolute';
|
import relativeToAbsolute from '../lib/relative-to-absolute';
|
||||||
import {TOOLBAR_MARGIN} from './koenig-toolbar';
|
import {TOOLBAR_MARGIN} from './koenig-toolbar';
|
||||||
import {computed} from '@ember/object';
|
import {computed} from '@ember/object';
|
||||||
|
@ -11,21 +12,6 @@ import {inject as service} from '@ember/service';
|
||||||
// TODO: handle via CSS?
|
// TODO: handle via CSS?
|
||||||
const TICK_ADJUSTMENT = 8;
|
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({
|
export default Component.extend({
|
||||||
config: service(),
|
config: service(),
|
||||||
|
|
||||||
|
@ -194,7 +180,7 @@ export default Component.extend({
|
||||||
scrollParent.scrollTop = scrollTop;
|
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() {
|
_positionToolbar() {
|
||||||
let containerRect = this.element.offsetParent.getBoundingClientRect();
|
let containerRect = this.element.offsetParent.getBoundingClientRect();
|
||||||
let rangeRect = this.linkRect || this._windowRange.getBoundingClientRect();
|
let rangeRect = this.linkRect || this._windowRange.getBoundingClientRect();
|
||||||
|
|
|
@ -1,13 +1,17 @@
|
||||||
{{#each itemSections as |section sectionIndex|}}
|
{{#each @itemSections as |section sectionIndex|}}
|
||||||
|
{{#if section.items}}
|
||||||
|
{{#if (or (not section.developerExperiment) (enable-developer-experiments))}}
|
||||||
<div class="flex flex-column justify-center h5 {{unless (eq sectionIndex 0) "mt4"}} mb4 nl4 nr4 pl4 pr4 bg-whitegrey midlightgrey ttu f-supersmall fw4 tracked-3" style="min-width: calc(100% + 3.2rem);">
|
<div class="flex flex-column justify-center h5 {{unless (eq sectionIndex 0) "mt4"}} mb4 nl4 nr4 pl4 pr4 bg-whitegrey midlightgrey ttu f-supersmall fw4 tracked-3" style="min-width: calc(100% + 3.2rem);">
|
||||||
{{section.title}}
|
{{section.title}}
|
||||||
</div>
|
</div>
|
||||||
{{#each section.items as |item|}}
|
{{#each section.items as |item|}}
|
||||||
{{#if (or (not item.developerExperiment) (and item.developerExperiment (enable-developer-experiments)))}}
|
{{#if (or (not item.developerExperiment) (enable-developer-experiments))}}
|
||||||
<div class="{{if item.selected "kg-cardmenu-card-selected"}} {{kg-style "cardmenu-card"}}" onclick={{action itemClicked item}} data-kg="cardmenu-card" role="menuitem">
|
<div class="{{if (eq item @selectedItem) "kg-cardmenu-card-selected"}} {{kg-style "cardmenu-card"}}" {{on "click" (fn @itemClicked item)}} data-kg="cardmenu-card" role="menuitem">
|
||||||
<div class="{{kg-style "cardmenu-icon"}} {{item.iconClass}}" aria-hidden="true">{{svg-jar item.icon class="w8 h8"}}</div>
|
<div class="{{kg-style "cardmenu-icon"}} {{item.iconClass}}" aria-hidden="true">{{svg-jar item.icon class="w8 h8"}}</div>
|
||||||
<div class="{{kg-style "cardmenu-label"}}">{{item.label}}</div>
|
<div class="{{kg-style "cardmenu-label"}}">{{item.label}}</div>
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
|
|
|
@ -1,9 +0,0 @@
|
||||||
import Component from '@ember/component';
|
|
||||||
|
|
||||||
export default Component.extend({
|
|
||||||
tagName: '',
|
|
||||||
|
|
||||||
itemSections: null,
|
|
||||||
|
|
||||||
itemClicked() {}
|
|
||||||
});
|
|
|
@ -1,4 +1,5 @@
|
||||||
import Component from '@ember/component';
|
import Component from '@ember/component';
|
||||||
|
import mobiledocParsers from 'mobiledoc-kit/parsers/mobiledoc';
|
||||||
import {CARD_MENU} from '../options/cards';
|
import {CARD_MENU} from '../options/cards';
|
||||||
import {computed} from '@ember/object';
|
import {computed} from '@ember/object';
|
||||||
import {htmlSafe} from '@ember/string';
|
import {htmlSafe} from '@ember/string';
|
||||||
|
@ -10,9 +11,9 @@ export default Component.extend({
|
||||||
attributeBindings: ['style', 'data-kg'],
|
attributeBindings: ['style', 'data-kg'],
|
||||||
editor: null,
|
editor: null,
|
||||||
editorRange: null,
|
editorRange: null,
|
||||||
|
snippets: null,
|
||||||
|
|
||||||
// internal properties
|
// internal properties
|
||||||
itemSections: null,
|
|
||||||
showButton: false,
|
showButton: false,
|
||||||
showMenu: false,
|
showMenu: false,
|
||||||
top: 0,
|
top: 0,
|
||||||
|
@ -33,11 +34,37 @@ export default Component.extend({
|
||||||
return htmlSafe(`top: ${this.top}px`);
|
return htmlSafe(`top: ${this.top}px`);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
itemSections: computed('snippets.[]', function () {
|
||||||
|
let {snippets} = this;
|
||||||
|
let itemSections = [...CARD_MENU];
|
||||||
|
|
||||||
|
// TODO: move or create util, duplicated with koenig-slash-menu
|
||||||
|
if (snippets?.length) {
|
||||||
|
let snippetsSection = {
|
||||||
|
title: 'Snippets',
|
||||||
|
items: [],
|
||||||
|
rowLength: 1,
|
||||||
|
developerExperiment: true
|
||||||
|
};
|
||||||
|
|
||||||
|
snippets.forEach((snippet) => {
|
||||||
|
snippetsSection.items.push({
|
||||||
|
label: snippet.title,
|
||||||
|
icon: 'koenig/kg-card-type-bookmark',
|
||||||
|
type: 'snippet',
|
||||||
|
matches: [snippet.title.toLowerCase()]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
itemSections.push(snippetsSection);
|
||||||
|
}
|
||||||
|
|
||||||
|
return itemSections;
|
||||||
|
}),
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this._super(...arguments);
|
this._super(...arguments);
|
||||||
|
|
||||||
this.itemSections = CARD_MENU;
|
|
||||||
|
|
||||||
this._onResizeHandler = run.bind(this, this._handleResize);
|
this._onResizeHandler = run.bind(this, this._handleResize);
|
||||||
window.addEventListener('resize', this._onResizeHandler);
|
window.addEventListener('resize', this._onResizeHandler);
|
||||||
|
|
||||||
|
@ -89,13 +116,25 @@ export default Component.extend({
|
||||||
this._hideMenu();
|
this._hideMenu();
|
||||||
},
|
},
|
||||||
|
|
||||||
itemClicked(item) {
|
itemClicked(item, event) {
|
||||||
|
if (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
let range = this._editorRange;
|
let range = this._editorRange;
|
||||||
|
|
||||||
if (item.type === 'card') {
|
if (item.type === 'card') {
|
||||||
this.replaceWithCardSection(item.replaceArg, range, item.payload);
|
this.replaceWithCardSection(item.replaceArg, range, item.payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.tpye === 'snippet') {
|
||||||
|
let clickedSnippet = this.snippets.find(snippet => snippet.title === item.label);
|
||||||
|
if (clickedSnippet) {
|
||||||
|
let post = mobiledocParsers.parse(this.editor.builder, clickedSnippet.mobiledoc);
|
||||||
|
this.replaceWithPost(range, post);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this._hideButton();
|
this._hideButton();
|
||||||
this._hideMenu();
|
this._hideMenu();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,18 @@
|
||||||
{{#if (and this.showMenu this.itemSections)}}
|
<div
|
||||||
|
id="koenig-slash-menu"
|
||||||
|
class="absolute"
|
||||||
|
{{did-insert this.registerContainerElement}}
|
||||||
|
{{did-update this.registerEditor @editor}}
|
||||||
|
{{did-update this.handleCursorChange @editorRange}}
|
||||||
|
{{did-update this.updateItemSections @snippets}}
|
||||||
|
...attributes
|
||||||
|
>
|
||||||
|
{{#if (and this.showMenu this.itemSections)}}
|
||||||
<div class="koenig-cardmenu {{kg-style "cardmenu"}}" role="menu">
|
<div class="koenig-cardmenu {{kg-style "cardmenu"}}" role="menu">
|
||||||
<KoenigMenuContent @itemSections={{this.itemSections}} @itemClicked={{action "itemClicked"}} />
|
<KoenigMenuContent
|
||||||
|
@itemSections={{this.itemSections}}
|
||||||
|
@selectedItem={{this.selectedItem}}
|
||||||
|
@itemClicked={{this.itemClicked}} />
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
</div>
|
|
@ -1,40 +1,31 @@
|
||||||
import Component from '@ember/component';
|
import Component from '@glimmer/component';
|
||||||
|
import mobiledocParsers from 'mobiledoc-kit/parsers/mobiledoc';
|
||||||
import {CARD_MENU} from '../options/cards';
|
import {CARD_MENU} from '../options/cards';
|
||||||
import {assign} from '@ember/polyfills';
|
import {action} from '@ember/object';
|
||||||
import {computed, set} from '@ember/object';
|
|
||||||
import {htmlSafe} from '@ember/string';
|
|
||||||
import {isEmpty} from '@ember/utils';
|
import {isEmpty} from '@ember/utils';
|
||||||
import {run} from '@ember/runloop';
|
import {run} from '@ember/runloop';
|
||||||
|
import {tracked} from '@glimmer/tracking';
|
||||||
|
|
||||||
const ROW_LENGTH = 3;
|
const Y_OFFSET = 16;
|
||||||
|
|
||||||
export default Component.extend({
|
export default class KoenigSlashMenuComponent extends Component {
|
||||||
// public attrs
|
@tracked itemSections = [];
|
||||||
classNames: 'absolute',
|
@tracked showMenu = false;
|
||||||
attributeBindings: ['style'],
|
@tracked selectedRowIndex = 0;
|
||||||
editor: null,
|
@tracked selectedColumnIndex = 0;
|
||||||
editorRange: null,
|
|
||||||
|
|
||||||
// public properties
|
query = '';
|
||||||
showMenu: false,
|
|
||||||
top: 0,
|
|
||||||
itemSections: null,
|
|
||||||
|
|
||||||
// private properties
|
constructor() {
|
||||||
_openRange: null,
|
super(...arguments);
|
||||||
_query: '',
|
this.registerEditor(null, [this.args.editor]);
|
||||||
_onWindowMousedownHandler: null,
|
}
|
||||||
_yOffset: 16,
|
|
||||||
|
|
||||||
// closure actions
|
willDestroy() {
|
||||||
replaceWithCardSection() {},
|
window.removeEventListener('mousedown', this._onWindowMousedownHandler);
|
||||||
|
}
|
||||||
|
|
||||||
// computed properties
|
// create a 2-dimensional array of items based on the section row length, eg
|
||||||
style: computed('top', function () {
|
|
||||||
return htmlSafe(`top: ${this.top}px`);
|
|
||||||
}),
|
|
||||||
|
|
||||||
// create a 2-dimensional array of items based on the ROW_LENGTH, eg
|
|
||||||
// [
|
// [
|
||||||
// [item1, item1, item3]
|
// [item1, item1, item3]
|
||||||
// [item4, item5],
|
// [item4, item5],
|
||||||
|
@ -42,54 +33,130 @@ export default Component.extend({
|
||||||
// [item9]
|
// [item9]
|
||||||
// ]
|
// ]
|
||||||
// - used for arrow key movement of selected item
|
// - used for arrow key movement of selected item
|
||||||
itemMap: computed('itemSections', function () {
|
get itemMap() {
|
||||||
let map = [];
|
let itemMap = [];
|
||||||
|
|
||||||
this.itemSections.forEach((section) => {
|
this.itemSections.forEach((section) => {
|
||||||
let iterations = Math.ceil(section.items.length / ROW_LENGTH);
|
let iterations = Math.ceil(section.items.length / section.rowLength);
|
||||||
for (let i = 0; i < iterations; i++) {
|
for (let i = 0; i < iterations; i++) {
|
||||||
let startIndex = i * ROW_LENGTH;
|
let startIndex = i * section.rowLength;
|
||||||
map.push(section.items.slice(startIndex, startIndex + ROW_LENGTH));
|
itemMap.push(section.items.slice(startIndex, startIndex + section.rowLength));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return map;
|
return itemMap;
|
||||||
}),
|
}
|
||||||
|
|
||||||
didReceiveAttrs() {
|
get selectedItem() {
|
||||||
this._super(...arguments);
|
return this.itemMap[this.selectedRowIndex][this.selectedColumnIndex];
|
||||||
|
}
|
||||||
|
|
||||||
// re-register the / text input handler if the editor changes such as
|
@action
|
||||||
// when a "New post" is clicked from the sidebar or a different post
|
updateItemSections() {
|
||||||
// is loaded via search
|
let {query} = this;
|
||||||
if (this.editor !== this._lastEditor) {
|
let {snippets} = this.args;
|
||||||
this.editor.onTextInput({
|
|
||||||
|
let itemSections = [...CARD_MENU];
|
||||||
|
|
||||||
|
if (snippets?.length) {
|
||||||
|
let snippetsSection = {
|
||||||
|
title: 'Snippets',
|
||||||
|
items: [],
|
||||||
|
rowLength: 1,
|
||||||
|
developerExperiment: true
|
||||||
|
};
|
||||||
|
|
||||||
|
snippets.forEach((snippet) => {
|
||||||
|
snippetsSection.items.push({
|
||||||
|
label: snippet.title,
|
||||||
|
icon: 'koenig/kg-card-type-bookmark',
|
||||||
|
type: 'snippet',
|
||||||
|
matches: [snippet.title.toLowerCase()]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
itemSections.push(snippetsSection);
|
||||||
|
}
|
||||||
|
|
||||||
|
// match everything before a space to a card. Keeps the relevant
|
||||||
|
// card selected when providing attributes to a card, eg:
|
||||||
|
// /twitter https://twitter.com/EffinBirds/status/1001765208958881792
|
||||||
|
let card = query.split(/\s/)[0].replace(/^\//, '');
|
||||||
|
|
||||||
|
let matchedItems = itemSections.map((section) => {
|
||||||
|
// show all items before anything is typed
|
||||||
|
if (!card) {
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
// show icons where there's a match of the begining of one of the
|
||||||
|
// "item.matches" strings
|
||||||
|
let matches = section.items.filter(item => item.matches.any(match => match.indexOf(card) === 0));
|
||||||
|
if (matches.length > 0) {
|
||||||
|
return Object.assign({}, section, {items: matches});
|
||||||
|
}
|
||||||
|
}).compact();
|
||||||
|
|
||||||
|
if (query !== this._lastQuery) {
|
||||||
|
this.selectedRowIndex = 0;
|
||||||
|
this.selectedColumnIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.itemSections = matchedItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
registerContainerElement(element) {
|
||||||
|
this.containerElement = element;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
registerEditor(element, [editor]) {
|
||||||
|
// re-register the slash_menu text input handler if the editor changes
|
||||||
|
// such as when a "New post" is clicked from the sidebar or a different
|
||||||
|
// post is loaded via search
|
||||||
|
editor.onTextInput({
|
||||||
name: 'slash_menu',
|
name: 'slash_menu',
|
||||||
text: '/',
|
text: '/',
|
||||||
run: run.bind(this, this._showMenu)
|
run: this._showMenu.bind(this)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this._lastEditor = this.editor;
|
|
||||||
|
|
||||||
// re-position the menu and update the query if necessary when the
|
@action
|
||||||
// cursor position changes
|
handleCursorChange(element, [editorRange]) {
|
||||||
let editorRange = this.editorRange;
|
// update menu position to match cursor position
|
||||||
if (editorRange !== this._lastEditorRange) {
|
this._positionMenu(editorRange);
|
||||||
this._handleCursorChange(editorRange);
|
|
||||||
|
if (this.showMenu && editorRange) {
|
||||||
|
let {head: {section}} = editorRange;
|
||||||
|
|
||||||
|
// close the menu if we're on a non-slash section (eg, when / is deleted)
|
||||||
|
if (section && (section.text || section.text === '') && section.text.indexOf('/') !== 0) {
|
||||||
|
this._hideMenu();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
this._lastEditorRange = editorRange;
|
|
||||||
},
|
|
||||||
|
|
||||||
willDestroyElement() {
|
// update the query when the menu is open and cursor is in our open range
|
||||||
this._super(...arguments);
|
if (section === this._openRange.head.section) {
|
||||||
window.removeEventListener('mousedown', this._onMousedownHandler);
|
this.query = section.text.substring(
|
||||||
},
|
this._openRange.head.offset,
|
||||||
|
editorRange.head.offset
|
||||||
|
);
|
||||||
|
this._selectedItem = null;
|
||||||
|
this.updateItemSections();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
actions: {
|
@action
|
||||||
itemClicked(item, event) {
|
itemClicked(item, event) {
|
||||||
|
if (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
let range = this._openRange.head.section.toRange();
|
let range = this._openRange.head.section.toRange();
|
||||||
let [, ...params] = this._query.split(/\s/);
|
let [, ...params] = this.query.split(/\s/);
|
||||||
let payload = assign({}, item.payload);
|
let payload = Object.assign({}, item.payload);
|
||||||
|
|
||||||
// make sure the click doesn't propagate and get picked up by the
|
// make sure the click doesn't propagate and get picked up by the
|
||||||
// newly inserted card which can then remove itself because it
|
// newly inserted card which can then remove itself because it
|
||||||
|
@ -107,86 +174,56 @@ export default Component.extend({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.type === 'card') {
|
if (item.type === 'card') {
|
||||||
this.replaceWithCardSection(item.replaceArg, range, payload);
|
this.args.replaceWithCardSection(item.replaceArg, range, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.type === 'snippet') {
|
||||||
|
const clickedSnippet = this.args.snippets.find(snippet => snippet.title === item.label);
|
||||||
|
if (clickedSnippet) {
|
||||||
|
const post = mobiledocParsers.parse(this.args.editor.builder, clickedSnippet.mobiledoc);
|
||||||
|
this.args.replaceWithPost(range, post);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this._hideMenu();
|
this._hideMenu();
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
|
||||||
_handleCursorChange(editorRange) {
|
_positionMenu(range) {
|
||||||
// update menu position to match cursor position
|
if (!range) {
|
||||||
this._positionMenu(editorRange);
|
|
||||||
|
|
||||||
if (this.showMenu && editorRange) {
|
|
||||||
let {head: {section}} = editorRange;
|
|
||||||
|
|
||||||
// close the menu if we're on a non-slash section (eg, when / is deleted)
|
|
||||||
if (section && (section.text || section.text === '') && section.text.indexOf('/') !== 0) {
|
|
||||||
this._hideMenu();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// update the query when the menu is open and cursor is in our open range
|
let {head: {section}} = range;
|
||||||
if (section === this._openRange.head.section) {
|
|
||||||
let query = section.text.substring(
|
if (section && section.renderNode.element) {
|
||||||
this._openRange.head.offset,
|
let containerRect = this.containerElement.parentNode.getBoundingClientRect();
|
||||||
editorRange.head.offset
|
let selectedElement = section.renderNode.element;
|
||||||
);
|
let selectedElementRect = selectedElement.getBoundingClientRect();
|
||||||
this._updateQuery(query);
|
let top = selectedElementRect.top + selectedElementRect.height - containerRect.top + Y_OFFSET;
|
||||||
|
|
||||||
|
this.containerElement.style.top = `${top}px`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
|
||||||
_updateQuery(query) {
|
|
||||||
this._query = query;
|
|
||||||
|
|
||||||
// match everything before a space to a card. Keeps the relevant
|
|
||||||
// card selected when providing attributes to a card, eg:
|
|
||||||
// /twitter https://twitter.com/EffinBirds/status/1001765208958881792
|
|
||||||
let card = query.split(/\s/)[0].replace(/^\//, '');
|
|
||||||
|
|
||||||
let matchedItems = CARD_MENU.map((section) => {
|
|
||||||
// show all items before anything is typed
|
|
||||||
if (!card) {
|
|
||||||
return section;
|
|
||||||
}
|
|
||||||
|
|
||||||
// show icons where there's a match of the begining of one of the
|
|
||||||
// "item.matches" strings
|
|
||||||
let matches = section.items.filter(item => item.matches.any(match => match.indexOf(card) === 0));
|
|
||||||
if (matches.length > 0) {
|
|
||||||
return {title: section.title, items: matches};
|
|
||||||
}
|
|
||||||
}).compact();
|
|
||||||
|
|
||||||
// we need a copy to avoid modifying the object references
|
|
||||||
let sections = JSON.parse(JSON.stringify(matchedItems || []));
|
|
||||||
|
|
||||||
if (sections.length) {
|
|
||||||
set(sections[0].items[0], 'selected', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.set('itemSections', sections);
|
|
||||||
},
|
|
||||||
|
|
||||||
_showMenu() {
|
_showMenu() {
|
||||||
let editorRange = this.editorRange;
|
let {editorRange} = this.args;
|
||||||
let {head: {section}} = editorRange;
|
let {head: {section}} = editorRange;
|
||||||
|
|
||||||
// only show the menu if the slash is on an otherwise empty paragraph
|
// only show the menu if the slash is on an otherwise empty paragraph
|
||||||
if (!this.showMenu && editorRange.isCollapsed && section && !section.isListItem && section.text === '/') {
|
if (!this.showMenu && editorRange.isCollapsed && section && !section.isListItem && section.text === '/') {
|
||||||
this.set('showMenu', true);
|
this.showMenu = true;
|
||||||
|
|
||||||
// ensure all items are shown before we have a query filter
|
// ensure all items are shown before we have a query filter
|
||||||
this._updateQuery('');
|
this.query = '';
|
||||||
|
// this.set('_selectedItem', null);
|
||||||
|
this.updateItemSections();
|
||||||
|
|
||||||
// store a ref to the range when the menu was triggered so that we
|
// store a ref to the range when the menu was triggered so that we
|
||||||
// can query text after the slash
|
// can query text after the slash
|
||||||
this._openRange = this.editorRange;
|
this._openRange = editorRange;
|
||||||
|
|
||||||
// set up key handlers for selection & closing
|
// set up key handlers for selection & closing
|
||||||
this._registerKeyboardNavHandlers();
|
this._registerEditorKeyboardNavHandlers();
|
||||||
|
|
||||||
// watch the window for mousedown events so that we can close the
|
// watch the window for mousedown events so that we can close the
|
||||||
// menu when we detect a click outside. This is preferable to
|
// menu when we detect a click outside. This is preferable to
|
||||||
|
@ -197,19 +234,75 @@ export default Component.extend({
|
||||||
});
|
});
|
||||||
window.addEventListener('mousedown', this._onWindowMousedownHandler);
|
window.addEventListener('mousedown', this._onWindowMousedownHandler);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
_hideMenu() {
|
_hideMenu() {
|
||||||
if (this.showMenu) {
|
if (this.showMenu) {
|
||||||
this.set('showMenu', false);
|
this.showMenu = false;
|
||||||
this._unregisterKeyboardNavHandlers();
|
this._unregisterEditorKeyboardNavHandlers();
|
||||||
window.removeEventListener('mousedown', this._onWindowMousedownHandler);
|
window.removeEventListener('mousedown', this._onWindowMousedownHandler);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
|
_moveSelection(direction) {
|
||||||
|
let {itemMap, selectedRowIndex, selectedColumnIndex} = this;
|
||||||
|
|
||||||
|
if (isEmpty(itemMap)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let maxSelectedRowIndex = itemMap.length - 1;
|
||||||
|
let maxSelectedColumnIndex = itemMap[selectedRowIndex].length - 1;
|
||||||
|
|
||||||
|
if (direction === 'right') {
|
||||||
|
selectedColumnIndex += 1;
|
||||||
|
if (selectedColumnIndex > maxSelectedColumnIndex) {
|
||||||
|
if (selectedRowIndex < maxSelectedRowIndex) {
|
||||||
|
selectedRowIndex += 1;
|
||||||
|
} else {
|
||||||
|
selectedRowIndex = 0;
|
||||||
|
}
|
||||||
|
selectedColumnIndex = 0;
|
||||||
|
}
|
||||||
|
} else if (direction === 'left') {
|
||||||
|
selectedColumnIndex -= 1;
|
||||||
|
if (selectedColumnIndex < 0) {
|
||||||
|
if (selectedRowIndex > 0) {
|
||||||
|
selectedRowIndex -= 1;
|
||||||
|
} else {
|
||||||
|
selectedRowIndex = itemMap.length - 1;
|
||||||
|
}
|
||||||
|
selectedColumnIndex = itemMap[selectedRowIndex].length - 1;
|
||||||
|
}
|
||||||
|
} else if (direction === 'up') {
|
||||||
|
selectedRowIndex -= 1;
|
||||||
|
if (selectedRowIndex < 0) {
|
||||||
|
selectedRowIndex = maxSelectedRowIndex;
|
||||||
|
}
|
||||||
|
} else if (direction === 'down') {
|
||||||
|
selectedRowIndex += 1;
|
||||||
|
if (selectedRowIndex > maxSelectedRowIndex) {
|
||||||
|
selectedRowIndex = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedColumnIndex > itemMap[selectedRowIndex].length - 1) {
|
||||||
|
selectedColumnIndex = itemMap[selectedRowIndex].length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedRowIndex = selectedRowIndex;
|
||||||
|
this.selectedColumnIndex = selectedColumnIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
_performAction() {
|
||||||
|
if (this.selectedItem) {
|
||||||
|
this.itemClicked(this.selectedItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_handleWindowMousedown(event) {
|
_handleWindowMousedown(event) {
|
||||||
// clicks outside the menu should always close
|
// clicks outside the menu should always close
|
||||||
if (!event.target.closest(`#${this.elementId}`)) {
|
if (!event.target.closest(`#${this.containerElement.id}`)) {
|
||||||
this._hideMenu();
|
this._hideMenu();
|
||||||
|
|
||||||
// clicks on the menu but not on a button should be ignored so that the
|
// clicks on the menu but not on a button should be ignored so that the
|
||||||
|
@ -217,29 +310,12 @@ export default Component.extend({
|
||||||
} else if (!event.target.closest('[data-kg="cardmenu-card"]')) {
|
} else if (!event.target.closest('[data-kg="cardmenu-card"]')) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
|
||||||
_positionMenu(range) {
|
|
||||||
if (!range) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let {head: {section}} = range;
|
_registerEditorKeyboardNavHandlers() {
|
||||||
|
|
||||||
if (section && section.renderNode.element) {
|
|
||||||
let containerRect = this.element.parentNode.getBoundingClientRect();
|
|
||||||
let selectedElement = section.renderNode.element;
|
|
||||||
let selectedElementRect = selectedElement.getBoundingClientRect();
|
|
||||||
let top = selectedElementRect.top + selectedElementRect.height - containerRect.top + this._yOffset;
|
|
||||||
|
|
||||||
this.set('top', top);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
_registerKeyboardNavHandlers() {
|
|
||||||
// ESC = close menu
|
// ESC = close menu
|
||||||
// ARROWS = selection
|
// ARROWS = selection
|
||||||
let editor = this.editor;
|
let {editor} = this.args;
|
||||||
|
|
||||||
editor.registerKeyCommand({
|
editor.registerKeyCommand({
|
||||||
str: 'ESC',
|
str: 'ESC',
|
||||||
|
@ -276,88 +352,9 @@ export default Component.extend({
|
||||||
name: 'slash-menu',
|
name: 'slash-menu',
|
||||||
run: run.bind(this, this._moveSelection, 'right')
|
run: run.bind(this, this._moveSelection, 'right')
|
||||||
});
|
});
|
||||||
},
|
|
||||||
|
|
||||||
_performAction() {
|
|
||||||
let selectedItem = this._getSelectedItem();
|
|
||||||
|
|
||||||
if (selectedItem) {
|
|
||||||
this.send('itemClicked', selectedItem);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
_getSelectedItem() {
|
|
||||||
let sections = this.itemSections;
|
|
||||||
|
|
||||||
if (sections.length <= 0) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let section of sections) {
|
_unregisterEditorKeyboardNavHandlers() {
|
||||||
let item = section.items.find(sectionItem => sectionItem.selected);
|
this.args.editor.unregisterKeyCommands('slash-menu');
|
||||||
if (item) {
|
|
||||||
return item;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
|
||||||
_moveSelection(direction) {
|
|
||||||
let itemMap = this.itemMap;
|
|
||||||
|
|
||||||
if (isEmpty(itemMap)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let selectedItem = this._getSelectedItem();
|
|
||||||
let selectedRow = itemMap.find(row => row.includes(selectedItem));
|
|
||||||
let selectedRowIndex = itemMap.indexOf(selectedRow);
|
|
||||||
let selectedItemIndex = selectedRow.indexOf(selectedItem);
|
|
||||||
let lastRowIndex = itemMap.length - 1;
|
|
||||||
let lastItemIndex = selectedRow.length - 1;
|
|
||||||
|
|
||||||
set(selectedItem, 'selected', false);
|
|
||||||
|
|
||||||
if (direction === 'right') {
|
|
||||||
selectedItemIndex += 1;
|
|
||||||
if (selectedItemIndex > lastItemIndex) {
|
|
||||||
if (selectedRowIndex < lastRowIndex) {
|
|
||||||
selectedRowIndex += 1;
|
|
||||||
} else {
|
|
||||||
selectedRowIndex = 0;
|
|
||||||
}
|
|
||||||
selectedItemIndex = 0;
|
|
||||||
}
|
|
||||||
} else if (direction === 'left') {
|
|
||||||
selectedItemIndex -= 1;
|
|
||||||
if (selectedItemIndex < 0) {
|
|
||||||
if (selectedRowIndex > 0) {
|
|
||||||
selectedRowIndex -= 1;
|
|
||||||
} else {
|
|
||||||
selectedRowIndex = itemMap.length - 1;
|
|
||||||
}
|
|
||||||
selectedItemIndex = itemMap[selectedRowIndex].length - 1;
|
|
||||||
}
|
|
||||||
} else if (direction === 'up') {
|
|
||||||
selectedRowIndex -= 1;
|
|
||||||
if (selectedRowIndex < 0) {
|
|
||||||
selectedRowIndex = lastRowIndex;
|
|
||||||
}
|
|
||||||
} else if (direction === 'down') {
|
|
||||||
selectedRowIndex += 1;
|
|
||||||
if (selectedRowIndex > lastRowIndex) {
|
|
||||||
selectedRowIndex = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedItemIndex > itemMap[selectedRowIndex].length - 1) {
|
|
||||||
selectedItemIndex = itemMap[selectedRowIndex].length - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
set(itemMap[selectedRowIndex][selectedItemIndex], 'selected', true);
|
|
||||||
},
|
|
||||||
|
|
||||||
_unregisterKeyboardNavHandlers() {
|
|
||||||
let editor = this.editor;
|
|
||||||
editor.unregisterKeyCommands('slash-menu');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
<div class="kg-input-bar absolute z-999" style={{this.style}} {{did-insert this.registerAndPositionElement}} ...attributes>
|
||||||
|
<input
|
||||||
|
placeholder="Snippet title"
|
||||||
|
value={{this.title}}
|
||||||
|
class="kg-link-input pa2 pr6 mih-100 ba br3 shadow-2 f8 lh-heading tracked-2 outline-0 b--blue h10 nudge-top--8"
|
||||||
|
{{on "input" this.titleInput}}
|
||||||
|
{{on "keydown" this.titleKeydown}}
|
||||||
|
{{did-insert this.focusInput}}
|
||||||
|
/>
|
||||||
|
</div>
|
|
@ -0,0 +1,181 @@
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
import getScrollParent from '../utils/get-scroll-parent';
|
||||||
|
import {TOOLBAR_MARGIN} from './koenig-toolbar';
|
||||||
|
import {action} from '@ember/object';
|
||||||
|
import {guidFor} from '@ember/object/internals';
|
||||||
|
import {run} from '@ember/runloop';
|
||||||
|
import {tracked} from '@glimmer/tracking';
|
||||||
|
|
||||||
|
// pixels that should be added to the `left` property of the tick adjustment styles
|
||||||
|
// TODO: handle via CSS?
|
||||||
|
const TICK_ADJUSTMENT = 8;
|
||||||
|
|
||||||
|
export default class KoenigSnippetInputComponent extends Component {
|
||||||
|
@tracked title = '';
|
||||||
|
@tracked style = ''.htmlSafe();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
|
||||||
|
// record the range now because the property is bound and will update
|
||||||
|
// when the selection changes
|
||||||
|
this._snippetRange = this.args.snippetRange;
|
||||||
|
|
||||||
|
// grab a window range so that we can use getBoundingClientRect. Using
|
||||||
|
// document.createRange is more efficient than doing editor.setRange
|
||||||
|
// because it doesn't trigger all of the selection changing side-effects
|
||||||
|
// TODO: extract MobiledocRange->NativeRange into a util
|
||||||
|
let editor = this.args.editor;
|
||||||
|
let cursor = editor.cursor;
|
||||||
|
let {head, tail} = this.args.snippetRange;
|
||||||
|
let {node: headNode, offset: headOffset} = cursor._findNodeForPosition(head);
|
||||||
|
let {node: tailNode, offset: tailOffset} = cursor._findNodeForPosition(tail);
|
||||||
|
let range = document.createRange();
|
||||||
|
range.setStart(headNode, headOffset);
|
||||||
|
range.setEnd(tailNode, tailOffset);
|
||||||
|
this._windowRange = range;
|
||||||
|
|
||||||
|
// watch the window for mousedown events so that we can close the menu
|
||||||
|
// when we detect a click outside
|
||||||
|
this._onMousedownHandler = run.bind(this, this._handleMousedown);
|
||||||
|
window.addEventListener('mousedown', this._onMousedownHandler);
|
||||||
|
|
||||||
|
// watch for keydown events so that we can close the menu on Escape
|
||||||
|
this._onKeydownHandler = run.bind(this, this._handleKeydown);
|
||||||
|
window.addEventListener('keydown', this._onKeydownHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
willDestroy() {
|
||||||
|
window.removeEventListener('mousedown', this._onMousedownHandler);
|
||||||
|
window.removeEventListener('keydown', this._onKeydownHandler);
|
||||||
|
this._removeStyleElement();
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
focusInput(element) {
|
||||||
|
let scrollParent = getScrollParent(element);
|
||||||
|
let scrollTop = scrollParent.scrollTop;
|
||||||
|
|
||||||
|
element.focus();
|
||||||
|
|
||||||
|
// reset the scroll position to avoid jumps
|
||||||
|
// TODO: why does the input focus cause a scroll to the bottom of the doc?
|
||||||
|
scrollParent.scrollTop = scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
titleKeydown(event) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
// prevent Enter from triggering in the editor and removing text
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
// convert selection into a mobiledoc document
|
||||||
|
let {snippetRange, editor} = this.args;
|
||||||
|
let mobiledoc = editor.serializePost(editor.post.trimTo(snippetRange), 'mobiledoc');
|
||||||
|
|
||||||
|
this.args.save({
|
||||||
|
title: event.target.value,
|
||||||
|
mobiledoc
|
||||||
|
}).then(() => {
|
||||||
|
this.args.cancel();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
titleInput(event) {
|
||||||
|
this.title = event.target.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: largely shared with {{koenig-toolbar}} and {{koenig-link-input}} - extract to a shared util?
|
||||||
|
@action
|
||||||
|
registerAndPositionElement(element) {
|
||||||
|
element.id = guidFor(element);
|
||||||
|
this.element = element;
|
||||||
|
|
||||||
|
let containerRect = this.element.offsetParent.getBoundingClientRect();
|
||||||
|
let rangeRect = this.args.snippetRect || this._windowRange.getBoundingClientRect();
|
||||||
|
let {width, height} = this.element.getBoundingClientRect();
|
||||||
|
let newPosition = {};
|
||||||
|
|
||||||
|
// rangeRect is relative to the viewport so we need to subtract the
|
||||||
|
// container measurements to get a position relative to the container
|
||||||
|
newPosition = {
|
||||||
|
top: rangeRect.top - containerRect.top - height - TOOLBAR_MARGIN,
|
||||||
|
left: rangeRect.left - containerRect.left + rangeRect.width / 2 - width / 2,
|
||||||
|
right: null
|
||||||
|
};
|
||||||
|
|
||||||
|
let tickPosition = 50;
|
||||||
|
// don't overflow left boundary
|
||||||
|
if (newPosition.left < 0) {
|
||||||
|
newPosition.left = 0;
|
||||||
|
|
||||||
|
// calculate the tick percentage position
|
||||||
|
let absTickPosition = rangeRect.left - containerRect.left + rangeRect.width / 2;
|
||||||
|
tickPosition = absTickPosition / width * 100;
|
||||||
|
if (tickPosition < 5) {
|
||||||
|
tickPosition = 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// same for right boundary
|
||||||
|
if (newPosition.left + width > containerRect.width) {
|
||||||
|
newPosition.left = null;
|
||||||
|
newPosition.right = 0;
|
||||||
|
|
||||||
|
// calculate the tick percentage position
|
||||||
|
let absTickPosition = rangeRect.right - containerRect.right - rangeRect.width / 2;
|
||||||
|
tickPosition = 100 + absTickPosition / width * 100;
|
||||||
|
if (tickPosition > 95) {
|
||||||
|
tickPosition = 95;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// the tick is a pseudo-element so we the only way we can affect it's
|
||||||
|
// style is by adding a style element to the head
|
||||||
|
this._removeStyleElement(); // reset to base styles
|
||||||
|
if (tickPosition !== 50) {
|
||||||
|
this._addStyleElement(`left: calc(${tickPosition}% - ${TICK_ADJUSTMENT}px)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the toolbar position
|
||||||
|
this.style = Object.keys(newPosition).map((style) => {
|
||||||
|
if (newPosition[style] !== null) {
|
||||||
|
return `${style}: ${newPosition[style]}px`;
|
||||||
|
}
|
||||||
|
}).compact().join('; ').htmlSafe();
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleMousedown(event) {
|
||||||
|
if (this.element && !event.target.closest(this.element.id)) {
|
||||||
|
this.args.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleKeydown(event) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
this._cancelAndReselect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_cancelAndReselect() {
|
||||||
|
this.args.cancel();
|
||||||
|
if (this._snippetRange) {
|
||||||
|
this.args.editor.selectRange(this._snippetRange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_addStyleElement(styles) {
|
||||||
|
let styleElement = document.createElement('style');
|
||||||
|
styleElement.id = `${this.element.id}-style`;
|
||||||
|
styleElement.innerHTML = `#${this.element.id}:before, #${this.element.id}:after { ${styles} }`;
|
||||||
|
document.head.appendChild(styleElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
_removeStyleElement() {
|
||||||
|
let styleElement = document.querySelector(`#${this.element.id}-style`);
|
||||||
|
if (styleElement) {
|
||||||
|
styleElement.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,7 +19,7 @@
|
||||||
{{svg-jar "koenig/kg-italic" class=(concat (if (or this.activeMarkupTagNames.isEm this.activeMarkupTagNames.isI) "fill-blue-l2" "fill-white") " w4 h4")}}
|
{{svg-jar "koenig/kg-italic" class=(concat (if (or this.activeMarkupTagNames.isEm this.activeMarkupTagNames.isI) "fill-blue-l2" "fill-white") " w4 h4")}}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{{#unless basicOnly}}
|
{{#unless this.basicOnly}}
|
||||||
<li class="ma0 lh-solid">
|
<li class="ma0 lh-solid">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -44,7 +44,7 @@
|
||||||
|
|
||||||
<li class="ma0 lh-solid kg-action-bar-divider bg-darkgrey-l2 h5" role="separator"></li>
|
<li class="ma0 lh-solid kg-action-bar-divider bg-darkgrey-l2 h5" role="separator"></li>
|
||||||
|
|
||||||
{{#unless basicOnly}}
|
{{#unless this.basicOnly}}
|
||||||
<li class="ma0 lh-solid">
|
<li class="ma0 lh-solid">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -66,4 +66,21 @@
|
||||||
{{svg-jar "koenig/kg-link" class=(concat (if this.activeMarkupTagNames.isA "fill-blue-l2" "fill-white") " w4 h4")}}
|
{{svg-jar "koenig/kg-link" class=(concat (if this.activeMarkupTagNames.isA "fill-blue-l2" "fill-white") " w4 h4")}}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
{{#if (enable-developer-experiments)}}
|
||||||
|
{{#unless this.basicOnly}}
|
||||||
|
<li class="ma0 lh-solid kg-action-bar-divider bg-darkgrey-l2 h5" role="separator"></li>
|
||||||
|
<li class="ma0 lh-solid">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Create snippet"
|
||||||
|
class="dib dim-lite link h9 w9 nudge-top--1"
|
||||||
|
{{on "click" @addSnippet}}
|
||||||
|
>
|
||||||
|
{{svg-jar "koenig/kg-quote" class="w4 h4"}}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{{/unless}}
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
</KgActionBar>
|
</KgActionBar>
|
||||||
|
|
|
@ -48,6 +48,7 @@ export default [
|
||||||
export const CARD_MENU = [
|
export const CARD_MENU = [
|
||||||
{
|
{
|
||||||
title: 'Primary',
|
title: 'Primary',
|
||||||
|
rowLength: 3,
|
||||||
items: [{
|
items: [{
|
||||||
label: 'Image',
|
label: 'Image',
|
||||||
icon: 'koenig/kg-card-type-image',
|
icon: 'koenig/kg-card-type-image',
|
||||||
|
@ -110,6 +111,7 @@ export const CARD_MENU = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Embed',
|
title: 'Embed',
|
||||||
|
rowLength: 3,
|
||||||
items: [{
|
items: [{
|
||||||
label: 'YouTube',
|
label: 'YouTube',
|
||||||
icon: 'koenig/kg-card-type-youtube',
|
icon: 'koenig/kg-card-type-youtube',
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
export default 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;
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export {default} from 'koenig-editor/components/koenig-snippet-input';
|
|
@ -5,13 +5,14 @@ import mockEmails from './config/emails';
|
||||||
import mockIntegrations from './config/integrations';
|
import mockIntegrations from './config/integrations';
|
||||||
import mockInvites from './config/invites';
|
import mockInvites from './config/invites';
|
||||||
import mockLabels from './config/labels';
|
import mockLabels from './config/labels';
|
||||||
import mockMembers, {mockMembersStats} from './config/members';
|
import mockMembers from './config/members';
|
||||||
import mockPages from './config/pages';
|
import mockPages from './config/pages';
|
||||||
import mockPosts from './config/posts';
|
import mockPosts from './config/posts';
|
||||||
import mockRoles from './config/roles';
|
import mockRoles from './config/roles';
|
||||||
import mockSettings from './config/settings';
|
import mockSettings from './config/settings';
|
||||||
import mockSite from './config/site';
|
import mockSite from './config/site';
|
||||||
import mockSlugs from './config/slugs';
|
import mockSlugs from './config/slugs';
|
||||||
|
import mockSnippets from './config/snippets';
|
||||||
import mockTags from './config/tags';
|
import mockTags from './config/tags';
|
||||||
import mockThemes from './config/themes';
|
import mockThemes from './config/themes';
|
||||||
import mockUploads from './config/uploads';
|
import mockUploads from './config/uploads';
|
||||||
|
@ -34,7 +35,7 @@ export default function () {
|
||||||
// this.put('/posts/:id/', versionMismatchResponse);
|
// this.put('/posts/:id/', versionMismatchResponse);
|
||||||
// mockTags(this);
|
// mockTags(this);
|
||||||
// this.loadFixtures('settings');
|
// this.loadFixtures('settings');
|
||||||
mockMembersStats(this);
|
mockSnippets(this);
|
||||||
|
|
||||||
// keep this line, it allows all other API requests to hit the real server
|
// keep this line, it allows all other API requests to hit the real server
|
||||||
this.passthrough();
|
this.passthrough();
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default function mockSnippets(server) {
|
||||||
|
server.get('/snippets/');
|
||||||
|
server.post('/snippets/');
|
||||||
|
server.put('/snippets/:id/');
|
||||||
|
server.del('/snippets/:id/');
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
import {Model} from 'ember-cli-mirage';
|
||||||
|
|
||||||
|
export default Model.extend({});
|
Loading…
Reference in New Issue