Isolate all markdown editor behaviour into gh-editor component

no issue
- move all existing markdown editor behaviour out of the editor controller and isolate it into a single component that can be swapped out
- split the `register/remove` functions of the `shortcuts-route` out into a separate `shortcuts` mixin
This commit is contained in:
Kevin Ansfield 2015-12-14 12:52:53 +00:00
parent 9b522457ef
commit 0cbd7d5c68
11 changed files with 242 additions and 208 deletions

View File

@ -32,7 +32,7 @@ export default TextArea.extend(EditorAPI, EditorShortcuts, EditorScroll, {
this.setFocus();
this.sendAction('setEditor', this);
this.attrs.setEditor(this);
run.scheduleOnce('afterRender', this, this.afterRenderEvent);
},
@ -43,22 +43,6 @@ export default TextArea.extend(EditorAPI, EditorShortcuts, EditorScroll, {
}
},
/**
* Disable editing in the textarea (used while an upload is in progress)
*/
disable() {
let textarea = this.get('element');
textarea.setAttribute('readonly', 'readonly');
},
/**
* Reenable editing in the textarea
*/
enable() {
let textarea = this.get('element');
textarea.removeAttribute('readonly');
},
actions: {
toggleCopyHTMLModal(generatedHTML) {
this.attrs.toggleCopyHTMLModal(generatedHTML);

View File

@ -1,20 +1,25 @@
import Ember from 'ember';
import ShortcutsMixin from 'ghost/mixins/shortcuts';
import imageManager from 'ghost/utils/ed-image-manager';
import editorShortcuts from 'ghost/utils/editor-shortcuts';
const {Component, computed, run} = Ember;
const {equal} = computed;
export default Component.extend({
export default Component.extend(ShortcutsMixin, {
tagName: 'section',
classNames: ['gh-view'],
classNames: ['view-container', 'view-editor'],
activeTab: 'markdown',
editor: null,
editorDisabled: undefined,
editorScrollInfo: null, // updated when gh-ed-editor component scrolls
height: null, // updated when markdown is rendered
shouldFocusEditor: false,
showCopyHTMLModal: false,
copyHTMLModalContent: null,
// updated when gh-ed-editor component scrolls
editorScrollInfo: null,
// updated when markdown is rendered
height: null,
activeTab: 'markdown',
shortcuts: editorShortcuts,
markdownActive: equal('activeTab', 'markdown'),
previewActive: equal('activeTab', 'preview'),
@ -25,8 +30,7 @@ export default Component.extend({
// stays in sync
scrollPosition: computed('editorScrollInfo', 'height', function () {
let scrollInfo = this.get('editorScrollInfo');
let $previewContent = this.get('$previewContent');
let $previewViewPort = this.get('$previewViewPort');
let {$previewContent, $previewViewPort} = this;
if (!scrollInfo || !$previewContent || !$previewViewPort) {
return 0;
@ -41,21 +45,23 @@ export default Component.extend({
return previewPosition;
}),
scheduleAfterRender() {
run.scheduleOnce('afterRender', this, this.afterRenderEvent);
},
didInsertElement() {
this._super(...arguments);
this.scheduleAfterRender();
this.registerShortcuts();
run.scheduleOnce('afterRender', this, this._cacheElements);
},
afterRenderEvent() {
let $previewViewPort = this.$('.js-entry-preview-content');
willDestroyElement() {
if (this.attrs.onTeardown) {
this.attrs.onTeardown();
}
this.removeShortcuts();
},
_cacheElements() {
// cache these elements for use in other methods
this.set('$previewViewPort', $previewViewPort);
this.set('$previewContent', this.$('.js-rendered-markdown'));
this.$previewViewPort = this.$('.js-entry-preview-content');
this.$previewContent = this.$('.js-rendered-markdown');
},
actions: {
@ -63,6 +69,52 @@ export default Component.extend({
this.set('activeTab', tab);
},
updateScrollInfo(scrollInfo) {
this.set('editorScrollInfo', scrollInfo);
},
updateHeight(height) {
this.set('height', height);
},
// set from a `sendAction` on the gh-ed-editor component,
// so that we get a reference for handling uploads.
setEditor(editor) {
this.set('editor', editor);
},
disableEditor() {
this.set('editorDisabled', true);
},
enableEditor() {
this.set('editorDisabled', undefined);
},
// The actual functionality is implemented in utils/ed-editor-shortcuts
editorShortcut(options) {
if (this.editor.$().is(':focus')) {
this.editor.shortcut(options.type);
}
},
// Match the uploaded file to a line in the editor, and update that line with a path reference
// ensuring that everything ends up in the correct place and format.
handleImgUpload(e, resultSrc) {
let editor = this.get('editor');
let editorValue = editor.getValue();
let replacement = imageManager.getSrcRange(editorValue, e.target);
let cursorPosition;
if (replacement) {
cursorPosition = replacement.start + resultSrc.length + 1;
if (replacement.needsParens) {
resultSrc = `(${resultSrc})`;
}
editor.replaceSelection(resultSrc, replacement.start, replacement.end, cursorPosition);
}
},
toggleCopyHTMLModal(generatedHTML) {
this.set('copyHTMLModalContent', generatedHTML);
this.toggleProperty('showCopyHTMLModal');

View File

@ -61,7 +61,7 @@ export default Mixin.create({
*/
scrollHandler() {
this.set('scrollThrottle', run.throttle(this, () => {
this.sendAction('updateScrollInfo', this.getScrollInfo());
this.attrs.updateScrollInfo(this.getScrollInfo());
}, 10));
},

View File

@ -1,7 +1,6 @@
import Ember from 'ember';
import PostModel from 'ghost/models/post';
import boundOneWay from 'ghost/utils/bound-one-way';
import imageManager from 'ghost/utils/ed-image-manager';
const {Mixin, RSVP, computed, inject, observer, run} = Ember;
const {alias} = computed;
@ -17,7 +16,6 @@ PostModel.eachAttribute(function (name) {
export default Mixin.create({
_autoSaveId: null,
_timedSaveId: null,
editor: null,
submitting: false,
showLeaveEditorModal: false,
@ -126,7 +124,7 @@ export default Mixin.create({
let markdown = model.get('markdown');
let title = model.get('title');
let titleScratch = model.get('titleScratch');
let scratch = this.get('editor').getValue();
let scratch = this.get('model.scratch');
let changedAttributes;
if (!this.tagNamesEqual()) {
@ -253,31 +251,9 @@ export default Mixin.create({
},
actions: {
save(options) {
let prevStatus = this.get('model.status');
let isNew = this.get('model.isNew');
cancelTimers() {
let autoSaveId = this._autoSaveId;
let timedSaveId = this._timedSaveId;
let psmController = this.get('postSettingsMenuController');
let promise, status;
options = options || {};
// when navigating quickly between pages autoSave will occasionally
// try to run after the editor has been torn down so bail out here
// before we throw errors
if (!this.get('editor').$()) {
return 0;
}
this.toggleProperty('submitting');
if (options.backgroundSave) {
// do not allow a post's status to be set to published by a background save
status = 'draft';
} else {
status = this.get('willPublish') ? 'published' : 'draft';
}
if (autoSaveId) {
run.cancel(autoSaveId);
@ -288,10 +264,30 @@ export default Mixin.create({
run.cancel(timedSaveId);
this._timedSaveId = null;
}
},
save(options) {
let prevStatus = this.get('model.status');
let isNew = this.get('model.isNew');
let psmController = this.get('postSettingsMenuController');
let promise, status;
options = options || {};
this.toggleProperty('submitting');
if (options.backgroundSave) {
// do not allow a post's status to be set to published by a background save
status = 'draft';
} else {
status = this.get('willPublish') ? 'published' : 'draft';
}
this.send('cancelTimers');
// Set the properties that are indirected
// set markdown equal to what's in the editor, minus the image markers.
this.set('model.markdown', this.get('editor').getValue());
this.set('model.markdown', this.get('model.scratch'));
this.set('model.status', status);
// Set a default title
@ -345,53 +341,12 @@ export default Mixin.create({
}
},
// set from a `sendAction` on the gh-ed-editor component,
// so that we get a reference for handling uploads.
setEditor(editor) {
this.set('editor', editor);
},
// fired from the gh-ed-preview component when an image upload starts
disableEditor() {
this.get('editor').disable();
},
// fired from the gh-ed-preview component when an image upload finishes
enableEditor() {
this.get('editor').enable();
},
// Match the uploaded file to a line in the editor, and update that line with a path reference
// ensuring that everything ends up in the correct place and format.
handleImgUpload(e, resultSrc) {
let editor = this.get('editor');
let editorValue = editor.getValue();
let replacement = imageManager.getSrcRange(editorValue, e.target);
let cursorPosition;
if (replacement) {
cursorPosition = replacement.start + resultSrc.length + 1;
if (replacement.needsParens) {
resultSrc = `(${resultSrc})`;
}
editor.replaceSelection(resultSrc, replacement.start, replacement.end, cursorPosition);
}
},
autoSaveNew() {
if (this.get('model.isNew')) {
this.send('save', {silent: true, backgroundSave: true});
}
},
updateEditorScrollInfo(scrollInfo) {
this.set('editorScrollInfo', scrollInfo);
},
updateHeight(height) {
this.set('height', height);
},
toggleLeaveEditorModal(transition) {
this.set('leaveEditorTransition', transition);
this.toggleProperty('showLeaveEditorModal');

View File

@ -1,13 +1,19 @@
import Ember from 'ember';
import ShortcutsRoute from 'ghost/mixins/shortcuts-route';
import styleBody from 'ghost/mixins/style-body';
import editorShortcuts from 'ghost/utils/editor-shortcuts';
import ctrlOrCmd from 'ghost/utils/ctrl-or-cmd';
const {Mixin, RSVP, run} = Ember;
let generalShortcuts = {};
generalShortcuts[`${ctrlOrCmd}+alt+p`] = 'publish';
generalShortcuts['alt+shift+z'] = 'toggleZenMode';
export default Mixin.create(styleBody, ShortcutsRoute, {
classNames: ['editor'],
shortcuts: generalShortcuts,
actions: {
save() {
this.get('controller').send('save');
@ -24,14 +30,6 @@ export default Mixin.create(styleBody, ShortcutsRoute, {
Ember.$('body').toggleClass('zen');
},
// The actual functionality is implemented in utils/ed-editor-shortcuts
editorShortcut(options) {
// Only fire editor shortcuts when the editor has focus.
if (this.get('controller.editor').$().is(':focus')) {
this.get('controller.editor').shortcut(options.type);
}
},
willTransition(transition) {
let controller = this.get('controller');
let scratch = controller.get('model.scratch');
@ -97,8 +95,6 @@ export default Mixin.create(styleBody, ShortcutsRoute, {
});
},
shortcuts: editorShortcuts,
attachModelHooks(controller, model) {
// this will allow us to track when the model is saved and update the controller
// so that we can be sure controller.hasDirtyAttributes is correct, without having to update the

View File

@ -1,16 +1,8 @@
/* global key */
import Ember from 'ember';
import ShortcutsMixin from 'ghost/mixins/shortcuts';
const {Mixin, run, typeOf} = Ember;
const {Mixin} = Ember;
// Configure KeyMaster to respond to all shortcuts,
// even inside of
// input, textarea, and select.
key.filter = function () {
return true;
};
key.setScope('default');
/**
* Only routes can implement shortcuts.
* If you need to trigger actions on the controller,
@ -44,39 +36,7 @@ key.setScope('default');
* To have all your shortcut work in all scopes, give it the scope "all".
* Find out more at the keymaster docs
*/
export default Mixin.create({
registerShortcuts() {
let shortcuts = this.get('shortcuts');
Object.keys(shortcuts).forEach((shortcut) => {
let scope = shortcuts[shortcut].scope || 'default';
let action = shortcuts[shortcut];
let options;
if (typeOf(action) !== 'string') {
options = action.options;
action = action.action;
}
key(shortcut, scope, (event) => {
// stop things like ctrl+s from actually opening a save dialogue
event.preventDefault();
run(this, function () {
this.send(action, options);
});
});
});
},
removeShortcuts() {
let shortcuts = this.get('shortcuts');
Object.keys(shortcuts).forEach((shortcut) => {
let scope = shortcuts[shortcut].scope || 'default';
key.unbind(shortcut, scope);
});
},
export default Mixin.create(ShortcutsMixin, {
activate() {
this._super(...arguments);
this.registerShortcuts();

View File

@ -0,0 +1,79 @@
/* global key */
import Ember from 'ember';
const {Mixin, run, typeOf} = Ember;
// Configure KeyMaster to respond to all shortcuts,
// even inside of
// input, textarea, and select.
key.filter = function () {
return true;
};
key.setScope('default');
/**
* Only routes can implement shortcuts.
* If you need to trigger actions on the controller,
* simply call them with `this.get('controller').send('action')`.
*
* To implement shortcuts, add this mixin to your `extend()`,
* and implement a `shortcuts` hash.
* In this hash, keys are shortcut combinations and values are route action names.
* (see [keymaster docs](https://github.com/madrobby/keymaster/blob/master/README.markdown)),
*
* ```javascript
* shortcuts: {
* 'ctrl+s, command+s': 'save',
* 'ctrl+alt+z': 'toggleZenMode'
* }
* ```
* For more complex actions, shortcuts can instead have their value
* be an object like {action, options}
* ```javascript
* shortcuts: {
* 'ctrl+k': {action: 'markdownShortcut', options: 'createLink'}
* }
* ```
* You can set the scope of your shortcut by passing a scope property.
* ```javascript
* shortcuts : {
* 'enter': {action : 'confirmModal', scope: 'modal'}
* }
* ```
* If you don't specify a scope, we use a default scope called "default".
* To have all your shortcut work in all scopes, give it the scope "all".
* Find out more at the keymaster docs
*/
export default Mixin.create({
registerShortcuts() {
let shortcuts = this.get('shortcuts');
Object.keys(shortcuts).forEach((shortcut) => {
let scope = shortcuts[shortcut].scope || 'default';
let action = shortcuts[shortcut];
let options;
if (typeOf(action) !== 'string') {
options = action.options;
action = action.action;
}
key(shortcut, scope, (event) => {
// stop things like ctrl+s from actually opening a save dialogue
event.preventDefault();
run(this, function () {
this.send(action, options);
});
});
});
},
removeShortcuts() {
let shortcuts = this.get('shortcuts');
Object.keys(shortcuts).forEach((shortcut) => {
let scope = shortcuts[shortcut].scope || 'default';
key.unbind(shortcut, scope);
});
}
});

View File

@ -1,4 +1,46 @@
{{yield this (action 'toggleCopyHTMLModal')}}
<section class="entry-markdown js-entry-markdown {{if markdownActive 'active'}}">
<header class="floatingheader">
<span class="desktop-tabs"><a class="markdown-help-label" href="" title="Markdown Help" {{action (route-action "toggleMarkdownHelpModal")}}>Markdown</a></span>
<span class="mobile-tabs">
<a href="#" {{action 'selectTab' 'markdown'}} class="{{if markdownActive 'active'}}">Markdown</a>
<a href="#" {{action 'selectTab' 'preview'}} class="{{if previewActive 'active'}}">Preview</a>
</span>
<a class="markdown-help-icon" href="" title="Markdown Help" {{action (route-action "toggleMarkdownHelpModal")}}><i class="icon-markdown"></i></a>
</header>
<section id="entry-markdown-content" class="entry-markdown-content">
{{gh-ed-editor classNames="markdown-editor js-markdown-editor"
tabindex="1"
spellcheck="true"
value=value
setEditor=(action "setEditor")
updateScrollInfo=(action "updateScrollInfo")
toggleCopyHTMLModal=(action "toggleCopyHTMLModal")
onFocusIn=editorFocused
height=height
focus=shouldFocusEditor
readonly=editorDisabled}}
</section>
</section>
<section class="entry-preview js-entry-preview {{if previewActive 'active'}}">
<header class="floatingheader">
<span class="desktop-tabs"><a target="_blank" href="{{previewUrl}}">Preview</a></span>
<span class="mobile-tabs">
<a href="#" {{action 'selectTab' 'markdown'}} class="{{if markdownActive 'active'}}">Markdown</a>
<a href="#" {{action 'selectTab' 'preview'}} class="{{if previewActive 'active'}}">Preview</a>
</span>
<span class="entry-word-count">{{gh-count-words value}}</span>
</header>
<section class="entry-preview-content js-entry-preview-content">
{{gh-ed-preview classNames="rendered-markdown js-rendered-markdown"
markdown=value
scrollPosition=scrollPosition
updateHeight=(action "updateHeight")
uploadStarted=(action "disableEditor")
uploadFinished=(action "enableEditor")
uploadSuccess=(action "handleImgUpload")}}
</section>
</section>
{{#if showCopyHTMLModal}}
{{gh-fullscreen-modal "copy-html"

View File

@ -1,4 +1,4 @@
{{#gh-editor editorScrollInfo=editorScrollInfo as |ghEditor toggleCopyHTMLModal|}}
<section class="gh-view">
<header class="view-header">
{{#gh-view-title classNames="gh-editor-title" openMobileMenu="openMobileMenu"}}
{{gh-trim-focus-input type="text" id="entry-title" placeholder="Your Post Title" value=model.titleScratch tabindex="1" focus=shouldFocusTitle}}
@ -20,47 +20,11 @@
</section>
</header>
<section class="view-container view-editor">
<section class="entry-markdown js-entry-markdown {{if ghEditor.markdownActive 'active'}}">
<header class="floatingheader">
<span class="desktop-tabs"><a class="markdown-help-label" href="" title="Markdown Help" {{action "toggleMarkdownHelpModal"}}>Markdown</a></span>
<span class="mobile-tabs">
<a href="#" {{action 'selectTab' 'markdown' target=ghEditor}} class="{{if ghEditor.markdownActive 'active'}}">Markdown</a>
<a href="#" {{action 'selectTab' 'preview' target=ghEditor}} class="{{if ghEditor.previewActive 'active'}}">Preview</a>
</span>
<a class="markdown-help-icon" href="" title="Markdown Help" {{action "toggleMarkdownHelpModal"}}><i class="icon-markdown"></i></a>
</header>
<section id="entry-markdown-content" class="entry-markdown-content">
{{gh-ed-editor classNames="markdown-editor js-markdown-editor"
tabindex="1"
spellcheck="true"
value=model.scratch
setEditor="setEditor"
updateScrollInfo="updateEditorScrollInfo"
toggleCopyHTMLModal=toggleCopyHTMLModal
onFocusIn="autoSaveNew"
height=height
focus=shouldFocusEditor}}
{{gh-editor value=model.scratch
shouldFocusEditor=shouldFocusEditor
editorFocused=(action "autoSaveNew")
onTeardown=(action "cancelTimers")}}
</section>
</section>
<section class="entry-preview js-entry-preview {{if ghEditor.previewActive 'active'}}">
<header class="floatingheader">
<span class="desktop-tabs"><a target="_blank" href="{{model.previewUrl}}">Preview</a></span>
<span class="mobile-tabs">
<a href="#" {{action 'selectTab' 'markdown' target=ghEditor}} class="{{if ghEditor.markdownActive 'active'}}">Markdown</a>
<a href="#" {{action 'selectTab' 'preview' target=ghEditor}} class="{{if ghEditor.previewActive 'active'}}">Preview</a>
</span>
<span class="entry-word-count">{{gh-count-words model.scratch}}</span>
</header>
<section class="entry-preview-content js-entry-preview-content">
{{gh-ed-preview classNames="rendered-markdown js-rendered-markdown"
markdown=model.scratch scrollPosition=ghEditor.scrollPosition updateHeight="updateHeight"
uploadStarted="disableEditor" uploadFinished="enableEditor" uploadSuccess="handleImgUpload"}}
</section>
</section>
</section>
{{/gh-editor}}
{{#if showDeletePostModal}}
{{gh-fullscreen-modal "delete-post"

View File

@ -1,14 +1,10 @@
// # Editor shortcuts
// Loaded by EditorBaseRoute, which is a shortcuts route
// Loaded by gh-editor component
// This map is used to ensure the right action is called by each shortcut
import ctrlOrCmd from 'ghost/utils/ctrl-or-cmd';
let shortcuts = {};
// General editor shortcuts
shortcuts[`${ctrlOrCmd}+alt+p`] = 'publish';
shortcuts['alt+shift+z'] = 'toggleZenMode';
// Markdown Shortcuts
// Text

View File

@ -9,9 +9,15 @@ describeComponent(
'gh-editor',
'Unit: Component: gh-editor',
{
unit: true
unit: true,
// specify the other units that are required for this test
// needs: ['component:foo', 'helper:bar']
needs: [
'component:gh-ed-editor',
'component:gh-ed-preview',
'helper:gh-count-words',
'helper:route-action',
'service:notifications'
]
},
function () {
it('renders', function () {