0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-10 23:36:14 -05:00

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
This commit is contained in:
David Arvelo 2014-06-23 21:17:57 -04:00
parent e0587ed79b
commit 6658675646
8 changed files with 288 additions and 127 deletions

View file

@ -512,6 +512,7 @@ var path = require('path'),
'bower_components/jquery-ui/ui/jquery-ui.js', 'bower_components/jquery-ui/ui/jquery-ui.js',
'bower_components/jquery-file-upload/js/jquery.fileupload.js', 'bower_components/jquery-file-upload/js/jquery.fileupload.js',
'bower_components/fastclick/lib/fastclick.js',
'bower_components/nprogress/nprogress.js', 'bower_components/nprogress/nprogress.js',
'core/shared/lib/showdown/extensions/ghostimagepreview.js', 'core/shared/lib/showdown/extensions/ghostimagepreview.js',

View file

@ -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;

View file

@ -1,8 +1,11 @@
/*global CodeMirror */ /*global CodeMirror */
import MarkerManager from 'ghost/mixins/marker-manager'; import MarkerManager from 'ghost/mixins/marker-manager';
import mobileCodeMirror from 'ghost/utils/codemirror-mobile';
import setScrollClassName from 'ghost/utils/set-scroll-classname'; 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 onChangeHandler = function (cm, changeObj) {
var line, var line,
@ -18,7 +21,7 @@ var onChangeHandler = function (cm, changeObj) {
// Is this a line which may have had a marker on it? // Is this a line which may have had a marker on it?
checkMarkers(); checkMarkers();
cm.component.set('value', cm.getDoc().getValue()); cm.component.set('value', cm.getValue());
}; };
var onScrollHandler = function (cm) { var onScrollHandler = function (cm) {
@ -41,9 +44,12 @@ var Codemirror = Ember.TextArea.extend(MarkerManager, {
afterRenderEvent: function () { afterRenderEvent: function () {
var initMarkers = _.bind(this.initMarkers, this); var initMarkers = _.bind(this.initMarkers, this);
// replaces CodeMirror with TouchEditor only if we're on mobile
mobileCodeMirror.createIfMobile();
this.initCodemirror(); this.initCodemirror();
this.codemirror.eachLine(initMarkers); 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 // this needs to be placed on the 'afterRender' queue otherwise CodeMirror gets wonky

View file

@ -10,7 +10,7 @@
<a class="markdown-help" href="" {{action "openModal" "markdown"}}><span class="hidden">What is Markdown?</span></a> <a class="markdown-help" href="" {{action "openModal" "markdown"}}><span class="hidden">What is Markdown?</span></a>
</header> </header>
<section id="entry-markdown-content" class="entry-markdown-content"> <section id="entry-markdown-content" class="entry-markdown-content">
{{gh-codemirror value=scratch scrollInfo=view.markdownScrollInfo action="setCodeMirror"}} {{gh-codemirror value=scratch scrollInfo=view.markdownScrollInfo setCodeMirror="setCodeMirror"}}
</section> </section>
</section> </section>

View file

@ -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
};

View file

@ -3,129 +3,135 @@
* See editor-route-base * See editor-route-base
*/ */
//Used for simple, noncomputational replace-and-go! shortcuts. function init() {
// See default case in shortcut function below. //Used for simple, noncomputational replace-and-go! shortcuts.
CodeMirror.prototype.simpleShortcutSyntax = { // See default case in shortcut function below.
bold: '**$1**', CodeMirror.prototype.simpleShortcutSyntax = {
italic: '*$1*', bold: '**$1**',
strike: '~~$1~~', italic: '*$1*',
code: '`$1`', strike: '~~$1~~',
link: '[$1](http://)', code: '`$1`',
image: '![$1](http://)', link: '[$1](http://)',
blockquote: '> $1' image: '![$1](http://)',
}; blockquote: '> $1'
CodeMirror.prototype.shortcut = function (type) { };
var text = this.getSelection(), CodeMirror.prototype.shortcut = function (type) {
cursor = this.getCursor(), var text = this.getSelection(),
line = this.getLine(cursor.line), cursor = this.getCursor(),
fromLineStart = {line: cursor.line, ch: 0}, line = this.getLine(cursor.line),
md, letterCount, textIndex, position; fromLineStart = {line: cursor.line, ch: 0},
switch (type) { md, letterCount, textIndex, position;
case 'h1': switch (type) {
this.replaceRange('# ' + line, fromLineStart); case 'h1':
this.setCursor(cursor.line, cursor.ch + 2); this.replaceRange('# ' + line, fromLineStart);
return; this.setCursor(cursor.line, cursor.ch + 2);
case 'h2': return;
this.replaceRange('## ' + line, fromLineStart); case 'h2':
this.setCursor(cursor.line, cursor.ch + 3); this.replaceRange('## ' + line, fromLineStart);
return; this.setCursor(cursor.line, cursor.ch + 3);
case 'h3': return;
this.replaceRange('### ' + line, fromLineStart); case 'h3':
this.setCursor(cursor.line, cursor.ch + 4); this.replaceRange('### ' + line, fromLineStart);
return; this.setCursor(cursor.line, cursor.ch + 4);
case 'h4': return;
this.replaceRange('#### ' + line, fromLineStart); case 'h4':
this.setCursor(cursor.line, cursor.ch + 5); this.replaceRange('#### ' + line, fromLineStart);
return; this.setCursor(cursor.line, cursor.ch + 5);
case 'h5': return;
this.replaceRange('##### ' + line, fromLineStart); case 'h5':
this.setCursor(cursor.line, cursor.ch + 6); this.replaceRange('##### ' + line, fromLineStart);
return; this.setCursor(cursor.line, cursor.ch + 6);
case 'h6': return;
this.replaceRange('###### ' + line, fromLineStart); case 'h6':
this.setCursor(cursor.line, cursor.ch + 7); this.replaceRange('###### ' + line, fromLineStart);
return; this.setCursor(cursor.line, cursor.ch + 7);
case 'link': return;
md = this.simpleShortcutSyntax.link.replace('$1', text); case 'link':
this.replaceSelection(md, 'end'); md = this.simpleShortcutSyntax.link.replace('$1', text);
if (!text) { this.replaceSelection(md, 'end');
this.setCursor(cursor.line, cursor.ch + 1); if (!text) {
} else { this.setCursor(cursor.line, cursor.ch + 1);
textIndex = line.indexOf(text, cursor.ch - text.length); } else {
position = textIndex + md.length - 1; textIndex = line.indexOf(text, cursor.ch - text.length);
this.setSelection({ position = textIndex + md.length - 1;
line: cursor.line, this.setSelection({
ch: position - 7 line: cursor.line,
}, { ch: position - 7
line: cursor.line, }, {
ch: position line: cursor.line,
}); ch: position
} });
return; }
case 'image': return;
md = this.simpleShortcutSyntax.image.replace('$1', text); case 'image':
if (line !== '') { md = this.simpleShortcutSyntax.image.replace('$1', text);
md = '\n\n' + md; if (line !== '') {
} md = '\n\n' + md;
this.replaceSelection(md, 'end'); }
cursor = this.getCursor(); this.replaceSelection(md, 'end');
this.setSelection({line: cursor.line, ch: cursor.ch - 8}, {line: cursor.line, ch: cursor.ch - 1}); cursor = this.getCursor();
return; this.setSelection({line: cursor.line, ch: cursor.ch - 8}, {line: cursor.line, ch: cursor.ch - 1});
case 'list': return;
md = text.replace(/^(\s*)(\w\W*)/gm, '$1* $2'); case 'list':
this.replaceSelection(md, 'end'); md = text.replace(/^(\s*)(\w\W*)/gm, '$1* $2');
return; this.replaceSelection(md, 'end');
case 'currentDate': return;
md = moment(new Date()).format('D MMMM YYYY'); case 'currentDate':
this.replaceSelection(md, 'end'); md = moment(new Date()).format('D MMMM YYYY');
return; this.replaceSelection(md, 'end');
/** @TODO return;
case 'uppercase': /** @TODO
md = text.toLocaleUpperCase(); case 'uppercase':
break; md = text.toLocaleUpperCase();
case 'lowercase': break;
md = text.toLocaleLowerCase(); case 'lowercase':
break; md = text.toLocaleLowerCase();
case 'titlecase': break;
md = text.toTitleCase(); case 'titlecase':
break; md = text.toTitleCase();
case 'selectword': break;
word = this.getTokenAt(cursor); case 'selectword':
if (!/\w$/g.test(word.string)) { word = this.getTokenAt(cursor);
this.setSelection({line: cursor.line, ch: word.start}, {line: cursor.line, ch: word.end - 1}); if (!/\w$/g.test(word.string)) {
} else { this.setSelection({line: cursor.line, ch: word.start}, {line: cursor.line, ch: word.end - 1});
this.setSelection({line: cursor.line, ch: word.start}, {line: cursor.line, ch: word.end}); } else {
} this.setSelection({line: cursor.line, ch: word.start}, {line: cursor.line, ch: word.end});
break; }
case 'copyHTML': break;
converter = new Showdown.converter(); case 'copyHTML':
if (text) { converter = new Showdown.converter();
md = converter.makeHtml(text); if (text) {
} else { md = converter.makeHtml(text);
md = converter.makeHtml(this.getValue()); } else {
} md = converter.makeHtml(this.getValue());
}
$(".modal-copyToHTML-content").text(md).selectText(); $(".modal-copyToHTML-content").text(md).selectText();
break; break;
case 'newLine': case 'newLine':
if (line !== "") { if (line !== "") {
this.replaceRange(line + "\n\n", fromLineStart); this.replaceRange(line + "\n\n", fromLineStart);
}
break;
*/
default:
if (this.simpleShortcutSyntax[type]) {
md = this.simpleShortcutSyntax[type].replace('$1', text);
}
} }
break; if (md) {
*/ this.replaceSelection(md, 'end');
default: if (!text) {
if (this.simpleShortcutSyntax[type]) { letterCount = md.length;
md = this.simpleShortcutSyntax[type].replace('$1', text); this.setCursor({
line: cursor.line,
ch: cursor.ch + (letterCount / 2)
});
}
} }
} };
if (md) { }
this.replaceSelection(md, 'end');
if (!text) { export default {
letterCount = md.length; init: init
this.setCursor({
line: cursor.line,
ch: cursor.ch + (letterCount / 2)
});
}
}
}; };

View file

@ -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
};

View file

@ -28,7 +28,7 @@ var Ghost = Ghost || {};
result = '<img class="js-upload-target" src="' + src + '"/>'; result = '<img class="js-upload-target" src="' + src + '"/>';
} }
if (Ghost && Ghost.touchEditor) { if ((Ghost && Ghost.touchEditor) || (typeof window !== 'undefined' && Ember.touchEditor)) {
output = '<section class="image-uploader">' + output = '<section class="image-uploader">' +
result + '<div class="description">Mobile uploads coming soon</div></section>'; result + '<div class="description">Mobile uploads coming soon</div></section>';
} else { } else {