From 665867564643ae8c6cb019b0827271b4324b4c9f Mon Sep 17 00:00:00 2001 From: David Arvelo Date: Mon, 23 Jun 2014 21:17:57 -0400 Subject: [PATCH] Implement Mobile Editor closes #2957 - add FastClick library to Gruntfile.js - add touch-editor to client/assets/lib/ - add mobile-specific utils to util/mobile-utils.js - add codemirror util to set up TouchEditor only if we're really on mobile - change gh-codemirror from having a default action to a named action. prevents Ember.TextArea firing action on change - change gh-codemirror `cm.getDoc().getValue()` to `cm.getValue()` for portability - change codemirror-shortcuts ES6 export/import style - changed ghostimagepreview.js to check for Ember.touchEditor in addition to Ghost.touchEditor --- Gruntfile.js | 1 + core/client/assets/lib/touch-editor.js | 55 ++++ core/client/components/gh-codemirror.js | 12 +- core/client/templates/editor/edit.hbs | 2 +- core/client/utils/codemirror-mobile.js | 45 ++++ core/client/utils/codemirror-shortcuts.js | 250 +++++++++--------- core/client/utils/mobile-utils.js | 48 ++++ .../showdown/extensions/ghostimagepreview.js | 2 +- 8 files changed, 288 insertions(+), 127 deletions(-) create mode 100644 core/client/assets/lib/touch-editor.js create mode 100644 core/client/utils/codemirror-mobile.js create mode 100644 core/client/utils/mobile-utils.js diff --git a/Gruntfile.js b/Gruntfile.js index 64fd1d38e4..d424301242 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -512,6 +512,7 @@ var path = require('path'), 'bower_components/jquery-ui/ui/jquery-ui.js', 'bower_components/jquery-file-upload/js/jquery.fileupload.js', + 'bower_components/fastclick/lib/fastclick.js', 'bower_components/nprogress/nprogress.js', 'core/shared/lib/showdown/extensions/ghostimagepreview.js', diff --git a/core/client/assets/lib/touch-editor.js b/core/client/assets/lib/touch-editor.js new file mode 100644 index 0000000000..e078a59d9c --- /dev/null +++ b/core/client/assets/lib/touch-editor.js @@ -0,0 +1,55 @@ +var createTouchEditor = function createTouchEditor() { + var noop = function () {}, + TouchEditor; + + TouchEditor = function (el, options) { + /*jshint unused:false*/ + this.textarea = el; + this.win = { document : this.textarea }; + this.ready = true; + this.wrapping = document.createElement('div'); + + var textareaParent = this.textarea.parentNode; + this.wrapping.appendChild(this.textarea); + textareaParent.appendChild(this.wrapping); + + this.textarea.style.opacity = 1; + }; + + TouchEditor.prototype = { + setOption: function (type, handler) { + if (type === 'onChange') { + $(this.textarea).change(handler); + } + }, + eachLine: function () { + return []; + }, + getValue: function () { + return this.textarea.value; + }, + setValue: function (code) { + this.textarea.value = code; + }, + focus: noop, + getCursor: function () { + return { line: 0, ch: 0 }; + }, + setCursor: noop, + currentLine: function () { + return 0; + }, + cursorPosition: function () { + return { character: 0 }; + }, + addMarkdown: noop, + nthLine: noop, + refresh: noop, + selectLines: noop, + on: noop + }; + + return TouchEditor; +}; + +export default createTouchEditor; diff --git a/core/client/components/gh-codemirror.js b/core/client/components/gh-codemirror.js index 6614f4083c..013ae4de9a 100644 --- a/core/client/components/gh-codemirror.js +++ b/core/client/components/gh-codemirror.js @@ -1,8 +1,11 @@ /*global CodeMirror */ import MarkerManager from 'ghost/mixins/marker-manager'; +import mobileCodeMirror from 'ghost/utils/codemirror-mobile'; import setScrollClassName from 'ghost/utils/set-scroll-classname'; -import 'ghost/utils/codemirror-shortcuts'; +import codeMirrorShortcuts from 'ghost/utils/codemirror-shortcuts'; + +codeMirrorShortcuts.init(); var onChangeHandler = function (cm, changeObj) { var line, @@ -18,7 +21,7 @@ var onChangeHandler = function (cm, changeObj) { // Is this a line which may have had a marker on it? checkMarkers(); - cm.component.set('value', cm.getDoc().getValue()); + cm.component.set('value', cm.getValue()); }; var onScrollHandler = function (cm) { @@ -41,9 +44,12 @@ var Codemirror = Ember.TextArea.extend(MarkerManager, { afterRenderEvent: function () { var initMarkers = _.bind(this.initMarkers, this); + // replaces CodeMirror with TouchEditor only if we're on mobile + mobileCodeMirror.createIfMobile(); + this.initCodemirror(); this.codemirror.eachLine(initMarkers); - this.sendAction('action', this); + this.sendAction('setCodeMirror', this); }, // this needs to be placed on the 'afterRender' queue otherwise CodeMirror gets wonky diff --git a/core/client/templates/editor/edit.hbs b/core/client/templates/editor/edit.hbs index c7d92b5340..e669b94103 100644 --- a/core/client/templates/editor/edit.hbs +++ b/core/client/templates/editor/edit.hbs @@ -10,7 +10,7 @@
- {{gh-codemirror value=scratch scrollInfo=view.markdownScrollInfo action="setCodeMirror"}} + {{gh-codemirror value=scratch scrollInfo=view.markdownScrollInfo setCodeMirror="setCodeMirror"}}
diff --git a/core/client/utils/codemirror-mobile.js b/core/client/utils/codemirror-mobile.js new file mode 100644 index 0000000000..12e5a40fde --- /dev/null +++ b/core/client/utils/codemirror-mobile.js @@ -0,0 +1,45 @@ +/*global CodeMirror*/ +import mobileUtils from 'ghost/utils/mobile-utils'; +import createTouchEditor from 'ghost/assets/lib/touch-editor'; + +var setupMobileCodeMirror, + TouchEditor, + init; + +setupMobileCodeMirror = function setupMobileCodeMirror() { + var noop = function () {}, + key; + + for (key in CodeMirror) { + if (CodeMirror.hasOwnProperty(key)) { + CodeMirror[key] = noop; + } + } + + CodeMirror.fromTextArea = function (el, options) { + return new TouchEditor(el, options); + }; + + CodeMirror.keyMap = { basic: {} }; +}; + +init = function init() { + if (mobileUtils.hasTouchScreen()) { + $('body').addClass('touch-editor'); + + // make editor tabs touch-to-toggle in portrait mode + $('.floatingheader').on('touchstart', function () { + $('.entry-markdown').toggleClass('active'); + $('.entry-preview').toggleClass('active'); + }); + + Ember.touchEditor = true; + mobileUtils.initFastClick(); + TouchEditor = createTouchEditor(); + setupMobileCodeMirror(); + } +}; + +export default { + createIfMobile: init +}; diff --git a/core/client/utils/codemirror-shortcuts.js b/core/client/utils/codemirror-shortcuts.js index 6e1d932607..2234b40e16 100644 --- a/core/client/utils/codemirror-shortcuts.js +++ b/core/client/utils/codemirror-shortcuts.js @@ -3,129 +3,135 @@ * See editor-route-base */ -//Used for simple, noncomputational replace-and-go! shortcuts. -// See default case in shortcut function below. -CodeMirror.prototype.simpleShortcutSyntax = { - bold: '**$1**', - italic: '*$1*', - strike: '~~$1~~', - code: '`$1`', - link: '[$1](http://)', - image: '![$1](http://)', - blockquote: '> $1' -}; -CodeMirror.prototype.shortcut = function (type) { - var text = this.getSelection(), - cursor = this.getCursor(), - line = this.getLine(cursor.line), - fromLineStart = {line: cursor.line, ch: 0}, - md, letterCount, textIndex, position; - switch (type) { - case 'h1': - this.replaceRange('# ' + line, fromLineStart); - this.setCursor(cursor.line, cursor.ch + 2); - return; - case 'h2': - this.replaceRange('## ' + line, fromLineStart); - this.setCursor(cursor.line, cursor.ch + 3); - return; - case 'h3': - this.replaceRange('### ' + line, fromLineStart); - this.setCursor(cursor.line, cursor.ch + 4); - return; - case 'h4': - this.replaceRange('#### ' + line, fromLineStart); - this.setCursor(cursor.line, cursor.ch + 5); - return; - case 'h5': - this.replaceRange('##### ' + line, fromLineStart); - this.setCursor(cursor.line, cursor.ch + 6); - return; - case 'h6': - this.replaceRange('###### ' + line, fromLineStart); - this.setCursor(cursor.line, cursor.ch + 7); - return; - case 'link': - md = this.simpleShortcutSyntax.link.replace('$1', text); - this.replaceSelection(md, 'end'); - if (!text) { - this.setCursor(cursor.line, cursor.ch + 1); - } else { - textIndex = line.indexOf(text, cursor.ch - text.length); - position = textIndex + md.length - 1; - this.setSelection({ - line: cursor.line, - ch: position - 7 - }, { - line: cursor.line, - ch: position - }); - } - return; - case 'image': - md = this.simpleShortcutSyntax.image.replace('$1', text); - if (line !== '') { - md = '\n\n' + md; - } - this.replaceSelection(md, 'end'); - cursor = this.getCursor(); - this.setSelection({line: cursor.line, ch: cursor.ch - 8}, {line: cursor.line, ch: cursor.ch - 1}); - return; - case 'list': - md = text.replace(/^(\s*)(\w\W*)/gm, '$1* $2'); - this.replaceSelection(md, 'end'); - return; - case 'currentDate': - md = moment(new Date()).format('D MMMM YYYY'); - this.replaceSelection(md, 'end'); - return; - /** @TODO - case 'uppercase': - md = text.toLocaleUpperCase(); - break; - case 'lowercase': - md = text.toLocaleLowerCase(); - break; - case 'titlecase': - md = text.toTitleCase(); - break; - case 'selectword': - word = this.getTokenAt(cursor); - if (!/\w$/g.test(word.string)) { - this.setSelection({line: cursor.line, ch: word.start}, {line: cursor.line, ch: word.end - 1}); - } else { - this.setSelection({line: cursor.line, ch: word.start}, {line: cursor.line, ch: word.end}); - } - break; - case 'copyHTML': - converter = new Showdown.converter(); - if (text) { - md = converter.makeHtml(text); - } else { - md = converter.makeHtml(this.getValue()); - } +function init() { + //Used for simple, noncomputational replace-and-go! shortcuts. + // See default case in shortcut function below. + CodeMirror.prototype.simpleShortcutSyntax = { + bold: '**$1**', + italic: '*$1*', + strike: '~~$1~~', + code: '`$1`', + link: '[$1](http://)', + image: '![$1](http://)', + blockquote: '> $1' + }; + CodeMirror.prototype.shortcut = function (type) { + var text = this.getSelection(), + cursor = this.getCursor(), + line = this.getLine(cursor.line), + fromLineStart = {line: cursor.line, ch: 0}, + md, letterCount, textIndex, position; + switch (type) { + case 'h1': + this.replaceRange('# ' + line, fromLineStart); + this.setCursor(cursor.line, cursor.ch + 2); + return; + case 'h2': + this.replaceRange('## ' + line, fromLineStart); + this.setCursor(cursor.line, cursor.ch + 3); + return; + case 'h3': + this.replaceRange('### ' + line, fromLineStart); + this.setCursor(cursor.line, cursor.ch + 4); + return; + case 'h4': + this.replaceRange('#### ' + line, fromLineStart); + this.setCursor(cursor.line, cursor.ch + 5); + return; + case 'h5': + this.replaceRange('##### ' + line, fromLineStart); + this.setCursor(cursor.line, cursor.ch + 6); + return; + case 'h6': + this.replaceRange('###### ' + line, fromLineStart); + this.setCursor(cursor.line, cursor.ch + 7); + return; + case 'link': + md = this.simpleShortcutSyntax.link.replace('$1', text); + this.replaceSelection(md, 'end'); + if (!text) { + this.setCursor(cursor.line, cursor.ch + 1); + } else { + textIndex = line.indexOf(text, cursor.ch - text.length); + position = textIndex + md.length - 1; + this.setSelection({ + line: cursor.line, + ch: position - 7 + }, { + line: cursor.line, + ch: position + }); + } + return; + case 'image': + md = this.simpleShortcutSyntax.image.replace('$1', text); + if (line !== '') { + md = '\n\n' + md; + } + this.replaceSelection(md, 'end'); + cursor = this.getCursor(); + this.setSelection({line: cursor.line, ch: cursor.ch - 8}, {line: cursor.line, ch: cursor.ch - 1}); + return; + case 'list': + md = text.replace(/^(\s*)(\w\W*)/gm, '$1* $2'); + this.replaceSelection(md, 'end'); + return; + case 'currentDate': + md = moment(new Date()).format('D MMMM YYYY'); + this.replaceSelection(md, 'end'); + return; + /** @TODO + case 'uppercase': + md = text.toLocaleUpperCase(); + break; + case 'lowercase': + md = text.toLocaleLowerCase(); + break; + case 'titlecase': + md = text.toTitleCase(); + break; + case 'selectword': + word = this.getTokenAt(cursor); + if (!/\w$/g.test(word.string)) { + this.setSelection({line: cursor.line, ch: word.start}, {line: cursor.line, ch: word.end - 1}); + } else { + this.setSelection({line: cursor.line, ch: word.start}, {line: cursor.line, ch: word.end}); + } + break; + case 'copyHTML': + converter = new Showdown.converter(); + if (text) { + md = converter.makeHtml(text); + } else { + md = converter.makeHtml(this.getValue()); + } - $(".modal-copyToHTML-content").text(md).selectText(); - break; - case 'newLine': - if (line !== "") { - this.replaceRange(line + "\n\n", fromLineStart); + $(".modal-copyToHTML-content").text(md).selectText(); + break; + case 'newLine': + if (line !== "") { + this.replaceRange(line + "\n\n", fromLineStart); + } + break; + */ + default: + if (this.simpleShortcutSyntax[type]) { + md = this.simpleShortcutSyntax[type].replace('$1', text); + } } - break; - */ - default: - if (this.simpleShortcutSyntax[type]) { - md = this.simpleShortcutSyntax[type].replace('$1', text); + if (md) { + this.replaceSelection(md, 'end'); + if (!text) { + letterCount = md.length; + this.setCursor({ + line: cursor.line, + ch: cursor.ch + (letterCount / 2) + }); + } } - } - if (md) { - this.replaceSelection(md, 'end'); - if (!text) { - letterCount = md.length; - this.setCursor({ - line: cursor.line, - ch: cursor.ch + (letterCount / 2) - }); - } - } + }; +} + +export default { + init: init }; \ No newline at end of file diff --git a/core/client/utils/mobile-utils.js b/core/client/utils/mobile-utils.js new file mode 100644 index 0000000000..2fd658aadc --- /dev/null +++ b/core/client/utils/mobile-utils.js @@ -0,0 +1,48 @@ +/*global DocumentTouch,FastClick*/ +var hasTouchScreen, + smallScreen, + initFastClick, + responsiveAction; + +// Taken from "Responsive design & the Guardian" with thanks to Matt Andrews +// Added !window._phantom so that the functional tests run as though this is not a touch screen. +// In future we can do something more advanced here for testing both touch and non touch +hasTouchScreen = function () { + return !window._phantom && + ( + ('ontouchstart' in window) || + (window.DocumentTouch && document instanceof DocumentTouch) + ); +}; + +smallScreen = function () { + if (window.matchMedia('(max-width: 1000px)').matches) { + return true; + } + + return false; +}; + +initFastClick = function () { + Ember.run.scheduleOnce('afterRender', null, function () { + FastClick.attach(document.body); + }); +}; + +responsiveAction = function responsiveAction(event, mediaCondition, cb) { + if (!window.matchMedia(mediaCondition).matches) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + cb(); +}; + +export { hasTouchScreen, smallScreen }; +export default { + hasTouchScreen: hasTouchScreen, + smallScreen: smallScreen, + initFastClick: initFastClick, + responsiveAction: responsiveAction +}; diff --git a/core/shared/lib/showdown/extensions/ghostimagepreview.js b/core/shared/lib/showdown/extensions/ghostimagepreview.js index 2932b806a4..9c4472da97 100644 --- a/core/shared/lib/showdown/extensions/ghostimagepreview.js +++ b/core/shared/lib/showdown/extensions/ghostimagepreview.js @@ -28,7 +28,7 @@ var Ghost = Ghost || {}; result = ''; } - if (Ghost && Ghost.touchEditor) { + if ((Ghost && Ghost.touchEditor) || (typeof window !== 'undefined' && Ember.touchEditor)) { output = '
' + result + '
Mobile uploads coming soon
'; } else {