mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-10 23:36:14 -05:00
Refactor the Ghost Editor
issue #2385, issue #2108 - Separate out the various objects which form the editor into their own modules - Decouple the modules where possible - Rename and reshuffle bits of modules for consistency - Minimise public APIs of the modules, and ensure they are consistent - Add comments to the modules
This commit is contained in:
parent
6943fa568c
commit
80bdfd7967
10 changed files with 926 additions and 676 deletions
22
Gruntfile.js
22
Gruntfile.js
|
@ -56,10 +56,7 @@ var path = require('path'),
|
|||
concat: {
|
||||
files: [
|
||||
'core/client/*.js',
|
||||
'core/client/helpers/*.js',
|
||||
'core/client/models/*.js',
|
||||
'core/client/tpl/*.js',
|
||||
'core/client/views/*.js'
|
||||
'core/client/**/*.js'
|
||||
],
|
||||
tasks: ['concat']
|
||||
},
|
||||
|
@ -482,7 +479,14 @@ var path = require('path'),
|
|||
'core/client/mobile-interactions.js',
|
||||
'core/client/toggle.js',
|
||||
'core/client/markdown-actions.js',
|
||||
'core/client/helpers/index.js'
|
||||
'core/client/helpers/index.js',
|
||||
'core/client/assets/lib/editor/index.js',
|
||||
'core/client/assets/lib/editor/markerManager.js',
|
||||
'core/client/assets/lib/editor/uploadManager.js',
|
||||
'core/client/assets/lib/editor/markdownEditor.js',
|
||||
'core/client/assets/lib/editor/htmlPreview.js',
|
||||
'core/client/assets/lib/editor/scrollHandler.js'
|
||||
|
||||
],
|
||||
|
||||
'core/built/scripts/templates.js': [
|
||||
|
@ -538,6 +542,14 @@ var path = require('path'),
|
|||
'core/client/markdown-actions.js',
|
||||
'core/client/helpers/index.js',
|
||||
|
||||
'core/client/assets/lib/editor/index.js',
|
||||
'core/client/assets/lib/editor/markerManager.js',
|
||||
'core/client/assets/lib/editor/uploadManager.js',
|
||||
'core/client/assets/lib/editor/markdownEditor.js',
|
||||
'core/client/assets/lib/editor/htmlPreview.js',
|
||||
'core/client/assets/lib/editor/scrollHandler.js',
|
||||
|
||||
|
||||
'core/client/tpl/hbs-tpl.js',
|
||||
|
||||
'core/client/models/**/*.js',
|
||||
|
|
44
core/client/assets/lib/editor/htmlPreview.js
Normal file
44
core/client/assets/lib/editor/htmlPreview.js
Normal file
|
@ -0,0 +1,44 @@
|
|||
// # Ghost Editor HTML Preview
|
||||
//
|
||||
// HTML Preview is the right pane in the split view editor.
|
||||
// It is effectively just a scrolling container for the HTML output from showdown
|
||||
// It knows how to update itself, and that's pretty much it.
|
||||
|
||||
/*global Ghost, Showdown, Countable, _, $ */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var HTMLPreview = function (markdown, uploadMgr) {
|
||||
var converter = new Showdown.converter({extensions: ['typography', 'ghostdown', 'github']}),
|
||||
preview = document.getElementsByClassName('rendered-markdown')[0],
|
||||
update;
|
||||
|
||||
// Update the preview
|
||||
// Includes replacing all the HTML, intialising upload dropzones, and updating the counter
|
||||
update = function () {
|
||||
preview.innerHTML = converter.makeHtml(markdown.value());
|
||||
|
||||
uploadMgr.enable();
|
||||
|
||||
Countable.once(preview, function (counter) {
|
||||
$('.entry-word-count').text($.pluralize(counter.words, 'word'));
|
||||
$('.entry-character-count').text($.pluralize(counter.characters, 'character'));
|
||||
$('.entry-paragraph-count').text($.pluralize(counter.paragraphs, 'paragraph'));
|
||||
});
|
||||
};
|
||||
|
||||
// Public API
|
||||
_.extend(this, {
|
||||
scrollViewPort: function () {
|
||||
return $('.entry-preview-content');
|
||||
},
|
||||
scrollContent: function () {
|
||||
return $('.rendered-markdown');
|
||||
},
|
||||
update: update
|
||||
});
|
||||
};
|
||||
|
||||
Ghost.Editor = Ghost.Editor || {};
|
||||
Ghost.Editor.HTMLPreview = HTMLPreview;
|
||||
} ());
|
79
core/client/assets/lib/editor/index.js
Normal file
79
core/client/assets/lib/editor/index.js
Normal file
|
@ -0,0 +1,79 @@
|
|||
// # Ghost Editor
|
||||
//
|
||||
// Ghost Editor contains a set of modules which make up the editor component
|
||||
// It manages the left and right panes, and all of the communication between them
|
||||
// Including scrolling,
|
||||
|
||||
/*global document, $, _, Ghost */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var Editor = function () {
|
||||
var self = this,
|
||||
$document = $(document),
|
||||
// Create all the needed editor components, passing them what they need to function
|
||||
markdown = new Ghost.Editor.MarkdownEditor(),
|
||||
uploadMgr = new Ghost.Editor.UploadManager(markdown),
|
||||
preview = new Ghost.Editor.HTMLPreview(markdown, uploadMgr),
|
||||
scrollHandler = new Ghost.Editor.ScrollHandler(markdown, preview),
|
||||
unloadDirtyMessage,
|
||||
handleChange,
|
||||
handleDrag;
|
||||
|
||||
unloadDirtyMessage = function () {
|
||||
return '==============================\n\n' +
|
||||
'Hey there! It looks like you\'re in the middle of writing' +
|
||||
' something and you haven\'t saved all of your content.' +
|
||||
'\n\nSave before you go!\n\n' +
|
||||
'==============================';
|
||||
};
|
||||
|
||||
handleChange = function () {
|
||||
self.setDirty(true);
|
||||
preview.update();
|
||||
};
|
||||
|
||||
handleDrag = function (e) {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
// Public API
|
||||
_.extend(this, {
|
||||
enable: function () {
|
||||
// Listen for changes
|
||||
$document.on('markdownEditorChange', handleChange);
|
||||
|
||||
// enable editing and scrolling
|
||||
markdown.enable();
|
||||
scrollHandler.enable();
|
||||
},
|
||||
|
||||
disable: function () {
|
||||
// Don't listen for changes
|
||||
$document.off('markdownEditorChange', handleChange);
|
||||
|
||||
// disable editing and scrolling
|
||||
markdown.disable();
|
||||
scrollHandler.disable();
|
||||
},
|
||||
|
||||
// Get the markdown value from the editor for saving
|
||||
// Upload manager makes sure the upload markers are removed beforehand
|
||||
value: function () {
|
||||
return uploadMgr.value();
|
||||
},
|
||||
|
||||
setDirty: function (dirty) {
|
||||
window.onbeforeunload = dirty ? unloadDirtyMessage : null;
|
||||
}
|
||||
});
|
||||
|
||||
// Initialise
|
||||
$document.on('drop dragover', handleDrag);
|
||||
preview.update();
|
||||
this.enable();
|
||||
};
|
||||
|
||||
Ghost.Editor = Ghost.Editor || {};
|
||||
Ghost.Editor.Main = Editor;
|
||||
}());
|
92
core/client/assets/lib/editor/markdownEditor.js
Normal file
92
core/client/assets/lib/editor/markdownEditor.js
Normal file
|
@ -0,0 +1,92 @@
|
|||
// # Ghost Editor Markdown Editor
|
||||
//
|
||||
// Markdown Editor is a light wrapper around CodeMirror
|
||||
|
||||
/*global Ghost, CodeMirror, shortcut, _, $ */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var MarkdownShortcuts,
|
||||
MarkdownEditor;
|
||||
|
||||
MarkdownShortcuts = [
|
||||
{'key': 'Ctrl+B', 'style': 'bold'},
|
||||
{'key': 'Meta+B', 'style': 'bold'},
|
||||
{'key': 'Ctrl+I', 'style': 'italic'},
|
||||
{'key': 'Meta+I', 'style': 'italic'},
|
||||
{'key': 'Ctrl+Alt+U', 'style': 'strike'},
|
||||
{'key': 'Ctrl+Shift+K', 'style': 'code'},
|
||||
{'key': 'Meta+K', 'style': 'code'},
|
||||
{'key': 'Ctrl+Alt+1', 'style': 'h1'},
|
||||
{'key': 'Ctrl+Alt+2', 'style': 'h2'},
|
||||
{'key': 'Ctrl+Alt+3', 'style': 'h3'},
|
||||
{'key': 'Ctrl+Alt+4', 'style': 'h4'},
|
||||
{'key': 'Ctrl+Alt+5', 'style': 'h5'},
|
||||
{'key': 'Ctrl+Alt+6', 'style': 'h6'},
|
||||
{'key': 'Ctrl+Shift+L', 'style': 'link'},
|
||||
{'key': 'Ctrl+Shift+I', 'style': 'image'},
|
||||
{'key': 'Ctrl+Q', 'style': 'blockquote'},
|
||||
{'key': 'Ctrl+Shift+1', 'style': 'currentDate'},
|
||||
{'key': 'Ctrl+U', 'style': 'uppercase'},
|
||||
{'key': 'Ctrl+Shift+U', 'style': 'lowercase'},
|
||||
{'key': 'Ctrl+Alt+Shift+U', 'style': 'titlecase'},
|
||||
{'key': 'Ctrl+Alt+W', 'style': 'selectword'},
|
||||
{'key': 'Ctrl+L', 'style': 'list'},
|
||||
{'key': 'Ctrl+Alt+C', 'style': 'copyHTML'},
|
||||
{'key': 'Meta+Alt+C', 'style': 'copyHTML'},
|
||||
{'key': 'Meta+Enter', 'style': 'newLine'},
|
||||
{'key': 'Ctrl+Enter', 'style': 'newLine'}
|
||||
];
|
||||
|
||||
MarkdownEditor = function () {
|
||||
var codemirror = CodeMirror.fromTextArea(document.getElementById('entry-markdown'), {
|
||||
mode: 'gfm',
|
||||
tabMode: 'indent',
|
||||
tabindex: '2',
|
||||
cursorScrollMargin: 10,
|
||||
lineWrapping: true,
|
||||
dragDrop: false,
|
||||
extraKeys: {
|
||||
Home: 'goLineLeft',
|
||||
End: 'goLineRight'
|
||||
}
|
||||
});
|
||||
|
||||
// Markdown shortcuts for the editor
|
||||
_.each(MarkdownShortcuts, function (combo) {
|
||||
shortcut.add(combo.key, function () {
|
||||
return codemirror.addMarkdown({style: combo.style});
|
||||
});
|
||||
});
|
||||
|
||||
// Public API
|
||||
_.extend(this, {
|
||||
codemirror: codemirror,
|
||||
|
||||
scrollViewPort: function () {
|
||||
return $('.CodeMirror-scroll');
|
||||
},
|
||||
scrollContent: function () {
|
||||
return $('.CodeMirror-sizer');
|
||||
},
|
||||
enable: function () {
|
||||
codemirror.setOption('readOnly', false);
|
||||
codemirror.on('change', function () {
|
||||
$(document).trigger('markdownEditorChange');
|
||||
});
|
||||
},
|
||||
disable: function () {
|
||||
codemirror.setOption('readOnly', 'nocursor');
|
||||
codemirror.off('change', function () {
|
||||
$(document).trigger('markdownEditorChange');
|
||||
});
|
||||
},
|
||||
value: function () {
|
||||
return codemirror.getValue();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
Ghost.Editor = Ghost.Editor || {};
|
||||
Ghost.Editor.MarkdownEditor = MarkdownEditor;
|
||||
} ());
|
147
core/client/assets/lib/editor/markerManager.js
Normal file
147
core/client/assets/lib/editor/markerManager.js
Normal file
|
@ -0,0 +1,147 @@
|
|||
// # Ghost Editor Marker Manager
|
||||
//
|
||||
// MarkerManager looks after the array of markers which are attached to image markdown in the editor.
|
||||
//
|
||||
// Marker Manager is told by the Upload Manager to add a marker to a line.
|
||||
// A marker takes the form of a 'magic id' which looks like:
|
||||
// {<1>}
|
||||
// It is appended to the start of the given line, and then defined as a CodeMirror 'TextMarker' widget which is
|
||||
// subsequently added to an array of markers to keep track of all markers in the editor.
|
||||
// The TextMarker is also set to 'collapsed' mode which means it does not show up in the display.
|
||||
// Currently, the markers can be seen if you copy and paste your content out of Ghost into a text editor.
|
||||
// The markers are stripped on save so should not appear in the DB
|
||||
|
||||
|
||||
/*global _, Ghost */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var imageMarkdownRegex = /^(?:\{<(.*?)>\})?!(?:\[([^\n\]]*)\])(?:\(([^\n\]]*)\))?$/gim,
|
||||
markerRegex = /\{<([\w\W]*?)>\}/,
|
||||
MarkerManager;
|
||||
|
||||
MarkerManager = function (editor) {
|
||||
var markers = {},
|
||||
uploadPrefix = 'image_upload',
|
||||
uploadId = 1,
|
||||
addMarker,
|
||||
removeMarker,
|
||||
markerRegexForId,
|
||||
stripMarkerFromLine,
|
||||
findAndStripMarker,
|
||||
checkMarkers,
|
||||
initMarkers;
|
||||
|
||||
// the regex
|
||||
markerRegexForId = function (id) {
|
||||
id = id.replace('image_upload_', '');
|
||||
return new RegExp('\\{<' + id + '>\\}', 'gmi');
|
||||
};
|
||||
|
||||
// Add a marker to the given line
|
||||
// Params:
|
||||
// line - CodeMirror LineHandle
|
||||
// ln - line number
|
||||
addMarker = function (line, ln) {
|
||||
var marker,
|
||||
magicId = '{<' + uploadId + '>}';
|
||||
editor.setLine(ln, magicId + line.text);
|
||||
marker = editor.markText(
|
||||
{line: ln, ch: 0},
|
||||
{line: ln, ch: (magicId.length)},
|
||||
{collapsed: true}
|
||||
);
|
||||
|
||||
markers[uploadPrefix + '_' + uploadId] = marker;
|
||||
uploadId += 1;
|
||||
};
|
||||
|
||||
// Remove a marker
|
||||
// Will be passed a LineHandle if we already know which line the marker is on
|
||||
removeMarker = function (id, marker, line) {
|
||||
delete markers[id];
|
||||
marker.clear();
|
||||
|
||||
if (line) {
|
||||
stripMarkerFromLine(line);
|
||||
} else {
|
||||
findAndStripMarker(id);
|
||||
}
|
||||
};
|
||||
|
||||
// Removes the marker on the given line if there is one
|
||||
stripMarkerFromLine = function (line) {
|
||||
var markerText = line.text.match(markerRegex),
|
||||
ln = editor.getLineNumber(line);
|
||||
|
||||
if (markerText) {
|
||||
editor.replaceRange(
|
||||
'',
|
||||
{line: ln, ch: markerText.index},
|
||||
{line: ln, ch: markerText.index + markerText[0].length}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Find a marker in the editor by id & remove it
|
||||
// Goes line by line to find the marker by it's text if we've lost track of the TextMarker
|
||||
findAndStripMarker = function (id) {
|
||||
editor.eachLine(function (line) {
|
||||
var markerText = markerRegexForId(id).exec(line.text),
|
||||
ln;
|
||||
|
||||
if (markerText) {
|
||||
ln = editor.getLineNumber(line);
|
||||
editor.replaceRange(
|
||||
'',
|
||||
{line: ln, ch: markerText.index},
|
||||
{line: ln, ch: markerText.index + markerText[0].length}
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Check each marker to see if it is still present in the editor and if it still corresponds to image markdown
|
||||
// If it is no longer a valid image, remove it
|
||||
checkMarkers = function () {
|
||||
_.each(markers, function (marker, id) {
|
||||
var line;
|
||||
marker = markers[id];
|
||||
if (marker.find()) {
|
||||
line = editor.getLineHandle(marker.find().from.line);
|
||||
if (!line.text.match(imageMarkdownRegex)) {
|
||||
removeMarker(id, marker, line);
|
||||
}
|
||||
} else {
|
||||
removeMarker(id, marker);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Add markers to the line if it needs one
|
||||
initMarkers = function (line) {
|
||||
var isImage = line.text.match(imageMarkdownRegex),
|
||||
hasMarker = line.text.match(markerRegex);
|
||||
|
||||
if (isImage && !hasMarker) {
|
||||
addMarker(line, editor.getLineNumber(line));
|
||||
}
|
||||
};
|
||||
|
||||
// Initialise
|
||||
editor.eachLine(initMarkers);
|
||||
|
||||
// Public API
|
||||
_.extend(this, {
|
||||
markers: markers,
|
||||
checkMarkers: checkMarkers,
|
||||
addMarker: addMarker,
|
||||
stripMarkerFromLine: stripMarkerFromLine,
|
||||
getMarkerRegexForId: markerRegexForId
|
||||
});
|
||||
};
|
||||
|
||||
Ghost.Editor = Ghost.Editor || {};
|
||||
Ghost.Editor.MarkerManager = MarkerManager;
|
||||
}());
|
43
core/client/assets/lib/editor/scrollHandler.js
Normal file
43
core/client/assets/lib/editor/scrollHandler.js
Normal file
|
@ -0,0 +1,43 @@
|
|||
// # Ghost Editor Scroll Handler
|
||||
//
|
||||
// Scroll Handler does the (currently very simple / naive) job of syncing the right pane with the left pane
|
||||
// as the right pane scrolls
|
||||
|
||||
/*global Ghost, _ */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var ScrollHandler = function (markdown, preview) {
|
||||
var $markdownViewPort = markdown.scrollViewPort(),
|
||||
$previewViewPort = preview.scrollViewPort(),
|
||||
$markdownContent = markdown.scrollContent(),
|
||||
$previewContent = preview.scrollContent(),
|
||||
syncScroll;
|
||||
|
||||
syncScroll = _.throttle(function () {
|
||||
// calc position
|
||||
var markdownHeight = $markdownContent.height() - $markdownViewPort.height(),
|
||||
previewHeight = $previewContent.height() - $previewViewPort.height(),
|
||||
ratio = previewHeight / markdownHeight,
|
||||
previewPosition = $markdownViewPort.scrollTop() * ratio;
|
||||
|
||||
// apply new scroll
|
||||
$previewViewPort.scrollTop(previewPosition);
|
||||
}, 10);
|
||||
|
||||
_.extend(this, {
|
||||
enable: function () { // Handle Scroll Events
|
||||
$markdownViewPort.on('scroll', syncScroll);
|
||||
$markdownViewPort.scrollClass({target: '.entry-markdown', offset: 10});
|
||||
$previewViewPort.scrollClass({target: '.entry-preview', offset: 10});
|
||||
},
|
||||
disable: function () {
|
||||
$markdownViewPort.off('scroll', syncScroll);
|
||||
}
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
Ghost.Editor = Ghost.Editor || {};
|
||||
Ghost.Editor.ScrollHandler = ScrollHandler;
|
||||
} ());
|
153
core/client/assets/lib/editor/uploadManager.js
Normal file
153
core/client/assets/lib/editor/uploadManager.js
Normal file
|
@ -0,0 +1,153 @@
|
|||
// # Ghost Editor Upload Manager
|
||||
//
|
||||
// UploadManager ensures that markdown gets updated when images get uploaded via the Preview.
|
||||
//
|
||||
// The Ghost Editor has a particularly tricky problem to solve, in that it is possible to upload an image by
|
||||
// interacting with the preview. The process of uploading an image is handled by uploader.js, but there is still
|
||||
// a lot of work needed to ensure that uploaded files end up in the right place - that is that the image
|
||||
// path gets added to the correct piece of markdown in the editor.
|
||||
//
|
||||
// To solve this, Ghost adds a unique 'marker' to each piece of markdown which represents an image:
|
||||
// More detail about how the markers work can be find in markerManager.js
|
||||
//
|
||||
// UploadManager handles changes in the editor, looking for text which matches image markdown, and telling the marker
|
||||
// manager to add a marker. It also checks changed lines to see if they have a marker but are no longer an image.
|
||||
//
|
||||
// UploadManager's most important job is handling uploads such that when a successful upload completes, the correct
|
||||
// piece of image markdown is updated with the path.
|
||||
// This is done in part by ghostImagePreview.js, which takes the marker from the markdown and uses it to create an ID
|
||||
// on the dropzone. When an upload completes successfully from uploader.js, the event thrown contains reference to the
|
||||
// dropzone, from which uploadManager can pull the ID & then get the right marker from the Marker Manager.
|
||||
//
|
||||
// Without a doubt, the separation of concerns between the uploadManager, and the markerManager could be vastly
|
||||
// improved
|
||||
|
||||
|
||||
/*global $, _, Ghost */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var imageMarkdownRegex = /^(?:\{<(.*?)>\})?!(?:\[([^\n\]]*)\])(?:\(([^\n\]]*)\))?$/gim,
|
||||
markerRegex = /\{<([\w\W]*?)>\}/,
|
||||
UploadManager;
|
||||
|
||||
UploadManager = function (markdown) {
|
||||
var editor = markdown.codemirror,
|
||||
markerMgr = new Ghost.Editor.MarkerManager(editor),
|
||||
findLine,
|
||||
checkLine,
|
||||
value,
|
||||
handleUpload,
|
||||
handleChange;
|
||||
|
||||
// Find the line with the marker which matches
|
||||
findLine = function (result_id) {
|
||||
// try to find the right line to replace
|
||||
if (markerMgr.markers.hasOwnProperty(result_id) && markerMgr.markers[result_id].find()) {
|
||||
return editor.getLineHandle(markerMgr.markers[result_id].find().from.line);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Check the given line to see if it has an image, and if it correctly has a marker
|
||||
// In the special case of lines which were just pasted in, any markers are removed to prevent duplication
|
||||
checkLine = function (ln, mode) {
|
||||
var line = editor.getLineHandle(ln),
|
||||
isImage = line.text.match(imageMarkdownRegex),
|
||||
hasMarker;
|
||||
|
||||
// We care if it is an image
|
||||
if (isImage) {
|
||||
hasMarker = line.text.match(markerRegex);
|
||||
|
||||
if (hasMarker && mode === 'paste') {
|
||||
// this could be a duplicate, and won't be a real marker
|
||||
markerMgr.stripMarkerFromLine(line);
|
||||
}
|
||||
|
||||
if (!hasMarker) {
|
||||
markerMgr.addMarker(line, ln);
|
||||
}
|
||||
}
|
||||
// TODO: hasMarker but no image?
|
||||
};
|
||||
|
||||
// Get the markdown with all the markers stripped
|
||||
value = function () {
|
||||
var value = editor.getValue();
|
||||
|
||||
_.each(markerMgr.markers, function (marker, id) {
|
||||
/*jshint unused:false*/
|
||||
value = value.replace(markerMgr.getMarkerRegexForId(id), '');
|
||||
});
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
// 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.
|
||||
handleUpload = function (e, result_src) {
|
||||
var line = findLine($(e.currentTarget).attr('id')),
|
||||
lineNumber = editor.getLineNumber(line),
|
||||
match = line.text.match(/\([^\n]*\)?/),
|
||||
replacement = '(http://)';
|
||||
|
||||
if (match) {
|
||||
// simple case, we have the parenthesis
|
||||
editor.setSelection(
|
||||
{line: lineNumber, ch: match.index + 1},
|
||||
{line: lineNumber, ch: match.index + match[0].length - 1}
|
||||
);
|
||||
} else {
|
||||
match = line.text.match(/\]/);
|
||||
if (match) {
|
||||
editor.replaceRange(
|
||||
replacement,
|
||||
{line: lineNumber, ch: match.index + 1},
|
||||
{line: lineNumber, ch: match.index + 1}
|
||||
);
|
||||
editor.setSelection(
|
||||
{line: lineNumber, ch: match.index + 2},
|
||||
{line: lineNumber, ch: match.index + replacement.length }
|
||||
);
|
||||
}
|
||||
}
|
||||
editor.replaceSelection(result_src);
|
||||
};
|
||||
|
||||
// Change events from CodeMirror tell us which lines have changed.
|
||||
// Each changed line is then checked to see if a marker needs to be added or removed
|
||||
handleChange = function (cm, changeObj) {
|
||||
/*jshint unused:false*/
|
||||
var linesChanged = _.range(changeObj.from.line, changeObj.from.line + changeObj.text.length);
|
||||
|
||||
_.each(linesChanged, function (ln) {
|
||||
checkLine(ln, changeObj.origin);
|
||||
});
|
||||
|
||||
// Is this a line which may have had a marker on it?
|
||||
markerMgr.checkMarkers();
|
||||
};
|
||||
|
||||
// Public API
|
||||
_.extend(this, {
|
||||
value: value,
|
||||
enable: function () {
|
||||
var filestorage = $('#entry-markdown-content').data('filestorage');
|
||||
$('.js-drop-zone').upload({editor: true, fileStorage: filestorage});
|
||||
$('.js-drop-zone').on('uploadstart', markdown.off);
|
||||
$('.js-drop-zone').on('uploadfailure', markdown.on);
|
||||
$('.js-drop-zone').on('uploadsuccess', markdown.on);
|
||||
$('.js-drop-zone').on('uploadsuccess', handleUpload);
|
||||
},
|
||||
disable: function () {
|
||||
$('.js-drop-zone').off('uploadsuccess', handleUpload);
|
||||
}
|
||||
});
|
||||
|
||||
editor.on('change', handleChange);
|
||||
};
|
||||
Ghost.Editor = Ghost.Editor || {};
|
||||
Ghost.Editor.UploadManager = UploadManager;
|
||||
}());
|
|
@ -130,13 +130,13 @@
|
|||
CodeMirror.prototype.addMarkdown.options = {
|
||||
style: null,
|
||||
syntax: {
|
||||
bold: "**$1**",
|
||||
italic: "*$1*",
|
||||
strike: "~~$1~~",
|
||||
code: "`$1`",
|
||||
link: "[$1](http://)",
|
||||
image: "![$1](http://)",
|
||||
blockquote: "> $1"
|
||||
bold: '**$1**',
|
||||
italic: '*$1*',
|
||||
strike: '~~$1~~',
|
||||
code: '`$1`',
|
||||
link: '[$1](http://)',
|
||||
image: '![$1](http://)',
|
||||
blockquote: '> $1'
|
||||
}
|
||||
};
|
||||
|
||||
|
|
255
core/client/views/editor-actions-widget.js
Normal file
255
core/client/views/editor-actions-widget.js
Normal file
|
@ -0,0 +1,255 @@
|
|||
// The Save / Publish button
|
||||
|
||||
/*global $, _, Ghost, shortcut */
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// The Publish, Queue, Publish Now buttons
|
||||
// ----------------------------------------
|
||||
Ghost.View.EditorActionsWidget = Ghost.View.extend({
|
||||
|
||||
events: {
|
||||
'click [data-set-status]': 'handleStatus',
|
||||
'click .js-publish-button': 'handlePostButton'
|
||||
},
|
||||
|
||||
statusMap: null,
|
||||
|
||||
createStatusMap: {
|
||||
'draft': 'Save Draft',
|
||||
'published': 'Publish Now'
|
||||
},
|
||||
|
||||
updateStatusMap: {
|
||||
'draft': 'Unpublish',
|
||||
'published': 'Update Post'
|
||||
},
|
||||
|
||||
//TODO: This has to be moved to the I18n localization file.
|
||||
//This structure is supposed to be close to the i18n-localization which will be used soon.
|
||||
messageMap: {
|
||||
errors: {
|
||||
post: {
|
||||
published: {
|
||||
'published': 'Your post could not be updated.',
|
||||
'draft': 'Your post could not be saved as a draft.'
|
||||
},
|
||||
draft: {
|
||||
'published': 'Your post could not be published.',
|
||||
'draft': 'Your post could not be saved as a draft.'
|
||||
}
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
success: {
|
||||
post: {
|
||||
published: {
|
||||
'published': 'Your post has been updated.',
|
||||
'draft': 'Your post has been saved as a draft.'
|
||||
},
|
||||
draft: {
|
||||
'published': 'Your post has been published.',
|
||||
'draft': 'Your post has been saved as a draft.'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
initialize: function () {
|
||||
var self = this;
|
||||
|
||||
// Toggle publish
|
||||
shortcut.add('Ctrl+Alt+P', function () {
|
||||
self.toggleStatus();
|
||||
});
|
||||
shortcut.add('Ctrl+S', function () {
|
||||
self.updatePost();
|
||||
});
|
||||
shortcut.add('Meta+S', function () {
|
||||
self.updatePost();
|
||||
});
|
||||
this.listenTo(this.model, 'change:status', this.render);
|
||||
},
|
||||
|
||||
toggleStatus: function () {
|
||||
var self = this,
|
||||
keys = Object.keys(this.statusMap),
|
||||
model = self.model,
|
||||
prevStatus = model.get('status'),
|
||||
currentIndex = keys.indexOf(prevStatus),
|
||||
newIndex,
|
||||
status;
|
||||
|
||||
newIndex = currentIndex + 1 > keys.length - 1 ? 0 : currentIndex + 1;
|
||||
status = keys[newIndex];
|
||||
|
||||
this.setActiveStatus(keys[newIndex], this.statusMap[status], prevStatus);
|
||||
|
||||
this.savePost({
|
||||
status: keys[newIndex]
|
||||
}).then(function () {
|
||||
self.reportSaveSuccess(status, prevStatus);
|
||||
}, function (xhr) {
|
||||
// Show a notification about the error
|
||||
self.reportSaveError(xhr, model, status, prevStatus);
|
||||
});
|
||||
},
|
||||
|
||||
setActiveStatus: function (newStatus, displayText, currentStatus) {
|
||||
var isPublishing = (newStatus === 'published' && currentStatus !== 'published'),
|
||||
isUnpublishing = (newStatus === 'draft' && currentStatus === 'published'),
|
||||
// Controls when background of button has the splitbutton-delete/button-delete classes applied
|
||||
isImportantStatus = (isPublishing || isUnpublishing);
|
||||
|
||||
$('.js-publish-splitbutton')
|
||||
.removeClass(isImportantStatus ? 'splitbutton-save' : 'splitbutton-delete')
|
||||
.addClass(isImportantStatus ? 'splitbutton-delete' : 'splitbutton-save');
|
||||
|
||||
// Set the publish button's action and proper coloring
|
||||
$('.js-publish-button')
|
||||
.attr('data-status', newStatus)
|
||||
.text(displayText)
|
||||
.removeClass(isImportantStatus ? 'button-save' : 'button-delete')
|
||||
.addClass(isImportantStatus ? 'button-delete' : 'button-save');
|
||||
|
||||
// Remove the animated popup arrow
|
||||
$('.js-publish-splitbutton > a')
|
||||
.removeClass('active');
|
||||
|
||||
// Set the active action in the popup
|
||||
$('.js-publish-splitbutton .editor-options li')
|
||||
.removeClass('active')
|
||||
.filter(['li[data-set-status="', newStatus, '"]'].join(''))
|
||||
.addClass('active');
|
||||
},
|
||||
|
||||
handleStatus: function (e) {
|
||||
if (e) { e.preventDefault(); }
|
||||
var status = $(e.currentTarget).attr('data-set-status'),
|
||||
currentStatus = this.model.get('status');
|
||||
|
||||
this.setActiveStatus(status, this.statusMap[status], currentStatus);
|
||||
|
||||
// Dismiss the popup menu
|
||||
$('body').find('.overlay:visible').fadeOut();
|
||||
},
|
||||
|
||||
handlePostButton: function (e) {
|
||||
if (e) { e.preventDefault(); }
|
||||
var status = $(e.currentTarget).attr('data-status');
|
||||
|
||||
this.updatePost(status);
|
||||
},
|
||||
|
||||
updatePost: function (status) {
|
||||
var self = this,
|
||||
model = this.model,
|
||||
prevStatus = model.get('status');
|
||||
|
||||
// Default to same status if not passed in
|
||||
status = status || prevStatus;
|
||||
|
||||
model.trigger('willSave');
|
||||
|
||||
this.savePost({
|
||||
status: status
|
||||
}).then(function () {
|
||||
self.reportSaveSuccess(status, prevStatus);
|
||||
// Refresh publish button and all relevant controls with updated status.
|
||||
self.render();
|
||||
}, function (xhr) {
|
||||
// Set the model status back to previous
|
||||
model.set({ status: prevStatus });
|
||||
// Set appropriate button status
|
||||
self.setActiveStatus(status, self.statusMap[status], prevStatus);
|
||||
// Show a notification about the error
|
||||
self.reportSaveError(xhr, model, status, prevStatus);
|
||||
});
|
||||
},
|
||||
|
||||
savePost: function (data) {
|
||||
var publishButton = $('.js-publish-button'),
|
||||
saved,
|
||||
enablePublish = function (deferred) {
|
||||
deferred.always(function () {
|
||||
publishButton.prop('disabled', false);
|
||||
});
|
||||
return deferred;
|
||||
};
|
||||
|
||||
publishButton.prop('disabled', true);
|
||||
|
||||
_.each(this.model.blacklist, function (item) {
|
||||
this.model.unset(item);
|
||||
}, this);
|
||||
|
||||
saved = this.model.save(_.extend({
|
||||
title: this.options.$title.val(),
|
||||
markdown: this.options.editor.value()
|
||||
}, data));
|
||||
|
||||
// TODO: Take this out if #2489 gets merged in Backbone. Or patch Backbone
|
||||
// ourselves for more consistent promises.
|
||||
if (saved) {
|
||||
return enablePublish(saved);
|
||||
}
|
||||
|
||||
return enablePublish($.Deferred().reject());
|
||||
},
|
||||
|
||||
reportSaveSuccess: function (status, prevStatus) {
|
||||
Ghost.notifications.clearEverything();
|
||||
Ghost.notifications.addItem({
|
||||
type: 'success',
|
||||
message: this.messageMap.success.post[prevStatus][status],
|
||||
status: 'passive'
|
||||
});
|
||||
this.options.editor.setDirty(false);
|
||||
},
|
||||
|
||||
reportSaveError: function (response, model, status, prevStatus) {
|
||||
var message = this.messageMap.errors.post[prevStatus][status];
|
||||
|
||||
if (response) {
|
||||
// Get message from response
|
||||
message += ' ' + Ghost.Views.Utils.getRequestErrorMessage(response);
|
||||
} else if (model.validationError) {
|
||||
// Grab a validation error
|
||||
message += ' ' + model.validationError;
|
||||
}
|
||||
|
||||
Ghost.notifications.clearEverything();
|
||||
Ghost.notifications.addItem({
|
||||
type: 'error',
|
||||
message: message,
|
||||
status: 'passive'
|
||||
});
|
||||
},
|
||||
|
||||
setStatusLabels: function (statusMap) {
|
||||
_.each(statusMap, function (label, status) {
|
||||
$('li[data-set-status="' + status + '"] > a').text(label);
|
||||
});
|
||||
},
|
||||
|
||||
render: function () {
|
||||
var status = this.model.get('status');
|
||||
|
||||
// Assume that we're creating a new post
|
||||
if (status !== 'published') {
|
||||
this.statusMap = this.createStatusMap;
|
||||
} else {
|
||||
this.statusMap = this.updateStatusMap;
|
||||
}
|
||||
|
||||
// Populate the publish menu with the appropriate verbiage
|
||||
this.setStatusLabels(this.statusMap);
|
||||
|
||||
// Default the selected publish option to the current status of the post.
|
||||
this.setActiveStatus(status, this.statusMap[status], status);
|
||||
}
|
||||
|
||||
});
|
||||
}());
|
|
@ -1,45 +1,10 @@
|
|||
// # Article Editor
|
||||
|
||||
/*global window, document, setTimeout, navigator, $, _, Backbone, Ghost, Showdown, CodeMirror, shortcut, Countable */
|
||||
/*global document, setTimeout, navigator, $, Backbone, Ghost, shortcut */
|
||||
(function () {
|
||||
"use strict";
|
||||
'use strict';
|
||||
|
||||
/*jslint regexp: true, bitwise: true */
|
||||
var PublishBar,
|
||||
ActionsWidget,
|
||||
UploadManager,
|
||||
MarkerManager,
|
||||
MarkdownShortcuts = [
|
||||
{'key': 'Ctrl+B', 'style': 'bold'},
|
||||
{'key': 'Meta+B', 'style': 'bold'},
|
||||
{'key': 'Ctrl+I', 'style': 'italic'},
|
||||
{'key': 'Meta+I', 'style': 'italic'},
|
||||
{'key': 'Ctrl+Alt+U', 'style': 'strike'},
|
||||
{'key': 'Ctrl+Shift+K', 'style': 'code'},
|
||||
{'key': 'Meta+K', 'style': 'code'},
|
||||
{'key': 'Ctrl+Alt+1', 'style': 'h1'},
|
||||
{'key': 'Ctrl+Alt+2', 'style': 'h2'},
|
||||
{'key': 'Ctrl+Alt+3', 'style': 'h3'},
|
||||
{'key': 'Ctrl+Alt+4', 'style': 'h4'},
|
||||
{'key': 'Ctrl+Alt+5', 'style': 'h5'},
|
||||
{'key': 'Ctrl+Alt+6', 'style': 'h6'},
|
||||
{'key': 'Ctrl+Shift+L', 'style': 'link'},
|
||||
{'key': 'Ctrl+Shift+I', 'style': 'image'},
|
||||
{'key': 'Ctrl+Q', 'style': 'blockquote'},
|
||||
{'key': 'Ctrl+Shift+1', 'style': 'currentDate'},
|
||||
{'key': 'Ctrl+U', 'style': 'uppercase'},
|
||||
{'key': 'Ctrl+Shift+U', 'style': 'lowercase'},
|
||||
{'key': 'Ctrl+Alt+Shift+U', 'style': 'titlecase'},
|
||||
{'key': 'Ctrl+Alt+W', 'style': 'selectword'},
|
||||
{'key': 'Ctrl+L', 'style': 'list'},
|
||||
{'key': 'Ctrl+Alt+C', 'style': 'copyHTML'},
|
||||
{'key': 'Meta+Alt+C', 'style': 'copyHTML'},
|
||||
{'key': 'Meta+Enter', 'style': 'newLine'},
|
||||
{'key': 'Ctrl+Enter', 'style': 'newLine'}
|
||||
],
|
||||
imageMarkdownRegex = /^(?:\{<(.*?)>\})?!(?:\[([^\n\]]*)\])(?:\(([^\n\]]*)\))?$/gim,
|
||||
markerRegex = /\{<([\w\W]*?)>\}/;
|
||||
/*jslint regexp: false, bitwise: false */
|
||||
var PublishBar;
|
||||
|
||||
// The publish bar associated with a post, which has the TagWidget and
|
||||
// Save button and options and such.
|
||||
|
@ -47,364 +12,88 @@
|
|||
PublishBar = Ghost.View.extend({
|
||||
|
||||
initialize: function () {
|
||||
this.addSubview(new Ghost.View.EditorTagWidget({el: this.$('#entry-tags'), model: this.model})).render();
|
||||
this.addSubview(new ActionsWidget({el: this.$('#entry-actions'), model: this.model})).render();
|
||||
this.addSubview(new Ghost.View.PostSettings({el: $('#entry-controls'), model: this.model})).render();
|
||||
|
||||
this.addSubview(new Ghost.View.EditorTagWidget(
|
||||
{el: this.$('#entry-tags'), model: this.model}
|
||||
)).render();
|
||||
this.addSubview(new Ghost.View.PostSettings(
|
||||
{el: $('#entry-controls'), model: this.model}
|
||||
)).render();
|
||||
|
||||
// Pass the Actions widget references to the title and editor so that it can get
|
||||
// the values that need to be saved
|
||||
this.addSubview(new Ghost.View.EditorActionsWidget(
|
||||
{
|
||||
el: this.$('#entry-actions'),
|
||||
model: this.model,
|
||||
$title: this.options.$title,
|
||||
editor: this.options.editor
|
||||
}
|
||||
)).render();
|
||||
|
||||
},
|
||||
|
||||
render: function () { return this; }
|
||||
|
||||
});
|
||||
|
||||
// The Publish, Queue, Publish Now buttons
|
||||
// ----------------------------------------
|
||||
ActionsWidget = Ghost.View.extend({
|
||||
|
||||
events: {
|
||||
'click [data-set-status]': 'handleStatus',
|
||||
'click .js-publish-button': 'handlePostButton'
|
||||
},
|
||||
|
||||
statusMap: null,
|
||||
|
||||
createStatusMap: {
|
||||
'draft': 'Save Draft',
|
||||
'published': 'Publish Now'
|
||||
},
|
||||
|
||||
updateStatusMap: {
|
||||
'draft': 'Unpublish',
|
||||
'published': 'Update Post'
|
||||
},
|
||||
|
||||
//TODO: This has to be moved to the I18n localization file.
|
||||
//This structure is supposed to be close to the i18n-localization which will be used soon.
|
||||
messageMap: {
|
||||
errors: {
|
||||
post: {
|
||||
published: {
|
||||
'published': 'Your post could not be updated.',
|
||||
'draft': 'Your post could not be saved as a draft.'
|
||||
},
|
||||
draft: {
|
||||
'published': 'Your post could not be published.',
|
||||
'draft': 'Your post could not be saved as a draft.'
|
||||
}
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
success: {
|
||||
post: {
|
||||
published: {
|
||||
'published': 'Your post has been updated.',
|
||||
'draft': 'Your post has been saved as a draft.'
|
||||
},
|
||||
draft: {
|
||||
'published': 'Your post has been published.',
|
||||
'draft': 'Your post has been saved as a draft.'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
initialize: function () {
|
||||
var self = this;
|
||||
// Toggle publish
|
||||
shortcut.add("Ctrl+Alt+P", function () {
|
||||
self.toggleStatus();
|
||||
});
|
||||
shortcut.add("Ctrl+S", function () {
|
||||
self.updatePost();
|
||||
});
|
||||
shortcut.add("Meta+S", function () {
|
||||
self.updatePost();
|
||||
});
|
||||
this.listenTo(this.model, 'change:status', this.render);
|
||||
},
|
||||
|
||||
toggleStatus: function () {
|
||||
var self = this,
|
||||
keys = Object.keys(this.statusMap),
|
||||
model = self.model,
|
||||
prevStatus = model.get('status'),
|
||||
currentIndex = keys.indexOf(prevStatus),
|
||||
newIndex,
|
||||
status;
|
||||
|
||||
newIndex = currentIndex + 1 > keys.length - 1 ? 0 : currentIndex + 1;
|
||||
status = keys[newIndex];
|
||||
|
||||
this.setActiveStatus(keys[newIndex], this.statusMap[status], prevStatus);
|
||||
|
||||
this.savePost({
|
||||
status: keys[newIndex]
|
||||
}).then(function () {
|
||||
self.reportSaveSuccess(status, prevStatus);
|
||||
}, function (xhr) {
|
||||
// Show a notification about the error
|
||||
self.reportSaveError(xhr, model, status, prevStatus);
|
||||
});
|
||||
},
|
||||
|
||||
setActiveStatus: function (newStatus, displayText, currentStatus) {
|
||||
var isPublishing = (newStatus === 'published' && currentStatus !== 'published'),
|
||||
isUnpublishing = (newStatus === 'draft' && currentStatus === 'published'),
|
||||
// Controls when background of button has the splitbutton-delete/button-delete classes applied
|
||||
isImportantStatus = (isPublishing || isUnpublishing);
|
||||
|
||||
$('.js-publish-splitbutton')
|
||||
.removeClass(isImportantStatus ? 'splitbutton-save' : 'splitbutton-delete')
|
||||
.addClass(isImportantStatus ? 'splitbutton-delete' : 'splitbutton-save');
|
||||
|
||||
// Set the publish button's action and proper coloring
|
||||
$('.js-publish-button')
|
||||
.attr('data-status', newStatus)
|
||||
.text(displayText)
|
||||
.removeClass(isImportantStatus ? 'button-save' : 'button-delete')
|
||||
.addClass(isImportantStatus ? 'button-delete' : 'button-save');
|
||||
|
||||
// Remove the animated popup arrow
|
||||
$('.js-publish-splitbutton > a')
|
||||
.removeClass('active');
|
||||
|
||||
// Set the active action in the popup
|
||||
$('.js-publish-splitbutton .editor-options li')
|
||||
.removeClass('active')
|
||||
.filter(['li[data-set-status="', newStatus, '"]'].join(''))
|
||||
.addClass('active');
|
||||
},
|
||||
|
||||
handleStatus: function (e) {
|
||||
if (e) { e.preventDefault(); }
|
||||
var status = $(e.currentTarget).attr('data-set-status'),
|
||||
currentStatus = this.model.get('status');
|
||||
|
||||
this.setActiveStatus(status, this.statusMap[status], currentStatus);
|
||||
|
||||
// Dismiss the popup menu
|
||||
$('body').find('.overlay:visible').fadeOut();
|
||||
},
|
||||
|
||||
handlePostButton: function (e) {
|
||||
if (e) { e.preventDefault(); }
|
||||
var status = $(e.currentTarget).attr('data-status');
|
||||
|
||||
this.updatePost(status);
|
||||
},
|
||||
|
||||
updatePost: function (status) {
|
||||
var self = this,
|
||||
model = this.model,
|
||||
prevStatus = model.get('status');
|
||||
|
||||
// Default to same status if not passed in
|
||||
status = status || prevStatus;
|
||||
|
||||
model.trigger('willSave');
|
||||
|
||||
this.savePost({
|
||||
status: status
|
||||
}).then(function () {
|
||||
self.reportSaveSuccess(status, prevStatus);
|
||||
// Refresh publish button and all relevant controls with updated status.
|
||||
self.render();
|
||||
}, function (xhr) {
|
||||
// Set the model status back to previous
|
||||
model.set({ status: prevStatus });
|
||||
// Set appropriate button status
|
||||
self.setActiveStatus(status, self.statusMap[status], prevStatus);
|
||||
// Show a notification about the error
|
||||
self.reportSaveError(xhr, model, status, prevStatus);
|
||||
});
|
||||
},
|
||||
|
||||
savePost: function (data) {
|
||||
var publishButton = $('.js-publish-button'),
|
||||
saved,
|
||||
enablePublish = function (deferred) {
|
||||
deferred.always(function () {
|
||||
publishButton.prop('disabled', false);
|
||||
});
|
||||
return deferred;
|
||||
};
|
||||
|
||||
publishButton.prop('disabled', true);
|
||||
|
||||
_.each(this.model.blacklist, function (item) {
|
||||
this.model.unset(item);
|
||||
}, this);
|
||||
|
||||
saved = this.model.save(_.extend({
|
||||
title: $('#entry-title').val(),
|
||||
markdown: Ghost.currentView.getEditorValue()
|
||||
}, data));
|
||||
|
||||
// TODO: Take this out if #2489 gets merged in Backbone. Or patch Backbone
|
||||
// ourselves for more consistent promises.
|
||||
if (saved) {
|
||||
return enablePublish(saved);
|
||||
}
|
||||
|
||||
return enablePublish($.Deferred().reject());
|
||||
},
|
||||
|
||||
reportSaveSuccess: function (status, prevStatus) {
|
||||
Ghost.notifications.clearEverything();
|
||||
Ghost.notifications.addItem({
|
||||
type: 'success',
|
||||
message: this.messageMap.success.post[prevStatus][status],
|
||||
status: 'passive'
|
||||
});
|
||||
Ghost.currentView.setEditorDirty(false);
|
||||
},
|
||||
|
||||
reportSaveError: function (response, model, status, prevStatus) {
|
||||
var message = this.messageMap.errors.post[prevStatus][status];
|
||||
|
||||
if (response) {
|
||||
// Get message from response
|
||||
message += " " + Ghost.Views.Utils.getRequestErrorMessage(response);
|
||||
} else if (model.validationError) {
|
||||
// Grab a validation error
|
||||
message += " " + model.validationError;
|
||||
}
|
||||
|
||||
Ghost.notifications.clearEverything();
|
||||
Ghost.notifications.addItem({
|
||||
type: 'error',
|
||||
message: message,
|
||||
status: 'passive'
|
||||
});
|
||||
},
|
||||
|
||||
setStatusLabels: function (statusMap) {
|
||||
_.each(statusMap, function (label, status) {
|
||||
$('li[data-set-status="' + status + '"] > a').text(label);
|
||||
});
|
||||
},
|
||||
|
||||
render: function () {
|
||||
var status = this.model.get('status');
|
||||
|
||||
// Assume that we're creating a new post
|
||||
if (status !== 'published') {
|
||||
this.statusMap = this.createStatusMap;
|
||||
} else {
|
||||
this.statusMap = this.updateStatusMap;
|
||||
}
|
||||
|
||||
// Populate the publish menu with the appropriate verbiage
|
||||
this.setStatusLabels(this.statusMap);
|
||||
|
||||
// Default the selected publish option to the current status of the post.
|
||||
this.setActiveStatus(status, this.statusMap[status], status);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
// The entire /editor page's route
|
||||
// ----------------------------------------
|
||||
Ghost.Views.Editor = Ghost.View.extend({
|
||||
|
||||
initialize: function () {
|
||||
var self = this;
|
||||
|
||||
// Add the container view for the Publish Bar
|
||||
this.addSubview(new PublishBar({el: "#publish-bar", model: this.model})).render();
|
||||
|
||||
this.$('#entry-title').val(this.model.get('title')).focus();
|
||||
this.$('#entry-markdown').text(this.model.get('markdown'));
|
||||
|
||||
this.listenTo(this.model, 'change:title', this.renderTitle);
|
||||
this.listenTo(this.model, 'change:id', function (m) {
|
||||
// This is a special case for browsers which fire an unload event when using navigate. The id change
|
||||
// happens before the save success and can cause the unload alert to appear incorrectly on first save
|
||||
// The id only changes in the event that the save has been successful, so this workaround is safes
|
||||
self.setEditorDirty(false);
|
||||
Backbone.history.navigate('/editor/' + m.id + '/');
|
||||
});
|
||||
|
||||
this.initMarkdown();
|
||||
this.renderPreview();
|
||||
|
||||
$('.entry-content header, .entry-preview header').on('click', function () {
|
||||
$('.entry-content, .entry-preview').removeClass('active');
|
||||
$(this).closest('section').addClass('active');
|
||||
});
|
||||
|
||||
$('.entry-title .icon-fullscreen').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
$('body').toggleClass('fullscreen');
|
||||
});
|
||||
|
||||
this.$('.CodeMirror-scroll').on('scroll', this.syncScroll);
|
||||
|
||||
this.$('.CodeMirror-scroll').scrollClass({target: '.entry-markdown', offset: 10});
|
||||
this.$('.entry-preview-content').scrollClass({target: '.entry-preview', offset: 10});
|
||||
|
||||
|
||||
// Zen writing mode shortcut
|
||||
shortcut.add("Alt+Shift+Z", function () {
|
||||
$('body').toggleClass('zen');
|
||||
});
|
||||
|
||||
$('.entry-markdown header, .entry-preview header').click(function (e) {
|
||||
$('.entry-markdown, .entry-preview').removeClass('active');
|
||||
$(e.target).closest('section').addClass('active');
|
||||
});
|
||||
|
||||
// Deactivate default drag/drop action
|
||||
$(document).bind('drop dragover', function (e) {
|
||||
e.preventDefault();
|
||||
});
|
||||
},
|
||||
|
||||
events: {
|
||||
'click .markdown-help': 'showHelp',
|
||||
'blur #entry-title': 'trimTitle',
|
||||
'orientationchange': 'orientationChange'
|
||||
},
|
||||
|
||||
syncScroll: _.throttle(function (e) {
|
||||
var $codeViewport = $(e.target),
|
||||
$previewViewport = $('.entry-preview-content'),
|
||||
$codeContent = $('.CodeMirror-sizer'),
|
||||
$previewContent = $('.rendered-markdown'),
|
||||
initialize: function () {
|
||||
this.$title = this.$('#entry-title');
|
||||
this.$editor = this.$('#entry-markdown');
|
||||
|
||||
// calc position
|
||||
codeHeight = $codeContent.height() - $codeViewport.height(),
|
||||
previewHeight = $previewContent.height() - $previewViewport.height(),
|
||||
ratio = previewHeight / codeHeight,
|
||||
previewPostition = $codeViewport.scrollTop() * ratio;
|
||||
this.$title.val(this.model.get('title')).focus();
|
||||
this.$editor.text(this.model.get('markdown'));
|
||||
|
||||
// apply new scroll
|
||||
$previewViewport.scrollTop(previewPostition);
|
||||
}, 10),
|
||||
// Create a new editor
|
||||
this.editor = new Ghost.Editor.Main();
|
||||
|
||||
showHelp: function () {
|
||||
this.addSubview(new Ghost.Views.Modal({
|
||||
model: {
|
||||
options: {
|
||||
close: true,
|
||||
style: ["wide"],
|
||||
animation: 'fade'
|
||||
},
|
||||
content: {
|
||||
template: 'markdown',
|
||||
title: 'Markdown Help'
|
||||
}
|
||||
}
|
||||
}));
|
||||
// Add the container view for the Publish Bar
|
||||
// Passing reference to the title and editor
|
||||
this.addSubview(new PublishBar(
|
||||
{el: '#publish-bar', model: this.model, $title: this.$title, editor: this.editor}
|
||||
)).render();
|
||||
|
||||
this.listenTo(this.model, 'change:title', this.renderTitle);
|
||||
this.listenTo(this.model, 'change:id', this.handleIdChange);
|
||||
|
||||
this.bindShortcuts();
|
||||
|
||||
$('.entry-markdown header, .entry-preview header').on('click', function (e) {
|
||||
$('.entry-markdown, .entry-preview').removeClass('active');
|
||||
$(e.currentTarget).closest('section').addClass('active');
|
||||
});
|
||||
},
|
||||
|
||||
bindShortcuts: function () {
|
||||
var self = this;
|
||||
|
||||
// Zen writing mode shortcut - full editor view
|
||||
shortcut.add('Alt+Shift+Z', function () {
|
||||
$('body').toggleClass('zen');
|
||||
});
|
||||
|
||||
// HTML copy & paste
|
||||
shortcut.add('Ctrl+Alt+C', function () {
|
||||
self.showHTML();
|
||||
});
|
||||
},
|
||||
|
||||
trimTitle: function () {
|
||||
var $title = $('#entry-title'),
|
||||
rawTitle = $title.val(),
|
||||
var rawTitle = this.$title.val(),
|
||||
trimmedTitle = $.trim(rawTitle);
|
||||
|
||||
if (rawTitle !== trimmedTitle) {
|
||||
$title.val(trimmedTitle);
|
||||
this.$title.val(trimmedTitle);
|
||||
}
|
||||
|
||||
// Trigger title change for post-settings.js
|
||||
|
@ -412,7 +101,15 @@
|
|||
},
|
||||
|
||||
renderTitle: function () {
|
||||
this.$('#entry-title').val(this.model.get('title'));
|
||||
this.$title.val(this.model.get('title'));
|
||||
},
|
||||
|
||||
handleIdChange: function (m) {
|
||||
// This is a special case for browsers which fire an unload event when using navigate. The id change
|
||||
// happens before the save success and can cause the unload alert to appear incorrectly on first save
|
||||
// The id only changes in the event that the save has been successful, so this workaround is safes
|
||||
this.editor.setDirty(false);
|
||||
Backbone.history.navigate('/editor/' + m.id + '/');
|
||||
},
|
||||
|
||||
// This is a hack to remove iOS6 white space on orientation change bug
|
||||
|
@ -427,307 +124,35 @@
|
|||
}
|
||||
},
|
||||
|
||||
// This updates the editor preview panel.
|
||||
// Currently gets called on every key press.
|
||||
// Also trigger word count update
|
||||
renderPreview: function () {
|
||||
var self = this,
|
||||
preview = document.getElementsByClassName('rendered-markdown')[0];
|
||||
preview.innerHTML = this.converter.makeHtml(this.editor.getValue());
|
||||
|
||||
this.initUploads();
|
||||
|
||||
Countable.once(preview, function (counter) {
|
||||
self.$('.entry-word-count').text($.pluralize(counter.words, 'word'));
|
||||
self.$('.entry-character-count').text($.pluralize(counter.characters, 'character'));
|
||||
self.$('.entry-paragraph-count').text($.pluralize(counter.paragraphs, 'paragraph'));
|
||||
});
|
||||
},
|
||||
|
||||
// Markdown converter & markdown shortcut initialization.
|
||||
initMarkdown: function () {
|
||||
var self = this;
|
||||
|
||||
this.converter = new Showdown.converter({extensions: ['typography', 'ghostdown', 'github']});
|
||||
this.editor = CodeMirror.fromTextArea(document.getElementById('entry-markdown'), {
|
||||
mode: 'gfm',
|
||||
tabMode: 'indent',
|
||||
tabindex: "2",
|
||||
lineWrapping: true,
|
||||
dragDrop: false,
|
||||
extraKeys: {
|
||||
Home: "goLineLeft",
|
||||
End: "goLineRight"
|
||||
}
|
||||
});
|
||||
this.uploadMgr = new UploadManager(this.editor);
|
||||
|
||||
// Inject modal for HTML to be viewed in
|
||||
shortcut.add("Ctrl+Alt+C", function () {
|
||||
self.showHTML();
|
||||
});
|
||||
shortcut.add("Ctrl+Alt+C", function () {
|
||||
self.showHTML();
|
||||
});
|
||||
|
||||
_.each(MarkdownShortcuts, function (combo) {
|
||||
shortcut.add(combo.key, function () {
|
||||
return self.editor.addMarkdown({style: combo.style});
|
||||
});
|
||||
});
|
||||
|
||||
this.enableEditor();
|
||||
},
|
||||
|
||||
options: {
|
||||
markers: {}
|
||||
},
|
||||
|
||||
getEditorValue: function () {
|
||||
return this.uploadMgr.getEditorValue();
|
||||
},
|
||||
|
||||
unloadDirtyMessage: function () {
|
||||
return "==============================\n\n" +
|
||||
"Hey there! It looks like you're in the middle of writing" +
|
||||
" something and you haven't saved all of your content." +
|
||||
"\n\nSave before you go!\n\n" +
|
||||
"==============================";
|
||||
},
|
||||
|
||||
setEditorDirty: function (dirty) {
|
||||
window.onbeforeunload = dirty ? this.unloadDirtyMessage : null;
|
||||
},
|
||||
|
||||
initUploads: function () {
|
||||
var filestorage = $('#entry-markdown-content').data('filestorage');
|
||||
this.$('.js-drop-zone').upload({editor: true, fileStorage: filestorage});
|
||||
this.$('.js-drop-zone').on('uploadstart', $.proxy(this.disableEditor, this));
|
||||
this.$('.js-drop-zone').on('uploadfailure', $.proxy(this.enableEditor, this));
|
||||
this.$('.js-drop-zone').on('uploadsuccess', $.proxy(this.enableEditor, this));
|
||||
this.$('.js-drop-zone').on('uploadsuccess', this.uploadMgr.handleUpload);
|
||||
},
|
||||
|
||||
enableEditor: function () {
|
||||
var self = this;
|
||||
this.editor.setOption("readOnly", false);
|
||||
this.editor.on('change', function () {
|
||||
self.setEditorDirty(true);
|
||||
self.renderPreview();
|
||||
});
|
||||
},
|
||||
|
||||
disableEditor: function () {
|
||||
var self = this;
|
||||
this.editor.setOption("readOnly", "nocursor");
|
||||
this.editor.off('change', function () {
|
||||
self.renderPreview();
|
||||
});
|
||||
},
|
||||
|
||||
showHTML: function () {
|
||||
showEditorModal: function (content) {
|
||||
this.addSubview(new Ghost.Views.Modal({
|
||||
model: {
|
||||
options: {
|
||||
close: true,
|
||||
style: ["wide"],
|
||||
style: ['wide'],
|
||||
animation: 'fade'
|
||||
},
|
||||
content: {
|
||||
template: 'copyToHTML',
|
||||
title: 'Copied HTML'
|
||||
}
|
||||
content: content
|
||||
}
|
||||
}));
|
||||
},
|
||||
|
||||
showHelp: function () {
|
||||
var content = {
|
||||
template: 'markdown',
|
||||
title: 'Markdown Help'
|
||||
};
|
||||
this.showEditorModal(content);
|
||||
},
|
||||
|
||||
showHTML: function () {
|
||||
var content = {
|
||||
template: 'copyToHTML',
|
||||
title: 'Copied HTML'
|
||||
};
|
||||
this.showEditorModal(content);
|
||||
},
|
||||
|
||||
render: function () { return this; }
|
||||
});
|
||||
|
||||
MarkerManager = function (editor) {
|
||||
var markers = {},
|
||||
uploadPrefix = 'image_upload',
|
||||
uploadId = 1;
|
||||
|
||||
function addMarker(line, ln) {
|
||||
var marker,
|
||||
magicId = '{<' + uploadId + '>}';
|
||||
editor.setLine(ln, magicId + line.text);
|
||||
marker = editor.markText(
|
||||
{line: ln, ch: 0},
|
||||
{line: ln, ch: (magicId.length)},
|
||||
{collapsed: true}
|
||||
);
|
||||
|
||||
markers[uploadPrefix + '_' + uploadId] = marker;
|
||||
uploadId += 1;
|
||||
}
|
||||
|
||||
function getMarkerRegexForId(id) {
|
||||
id = id.replace('image_upload_', '');
|
||||
return new RegExp('\\{<' + id + '>\\}', 'gmi');
|
||||
}
|
||||
|
||||
function stripMarkerFromLine(line) {
|
||||
var markerText = line.text.match(markerRegex),
|
||||
ln = editor.getLineNumber(line);
|
||||
|
||||
if (markerText) {
|
||||
editor.replaceRange('', {line: ln, ch: markerText.index}, {line: ln, ch: markerText.index + markerText[0].length});
|
||||
}
|
||||
}
|
||||
|
||||
function findAndStripMarker(id) {
|
||||
editor.eachLine(function (line) {
|
||||
var markerText = getMarkerRegexForId(id).exec(line.text),
|
||||
ln;
|
||||
|
||||
if (markerText) {
|
||||
ln = editor.getLineNumber(line);
|
||||
editor.replaceRange('', {line: ln, ch: markerText.index}, {line: ln, ch: markerText.index + markerText[0].length});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function removeMarker(id, marker, line) {
|
||||
delete markers[id];
|
||||
marker.clear();
|
||||
|
||||
if (line) {
|
||||
stripMarkerFromLine(line);
|
||||
} else {
|
||||
findAndStripMarker(id);
|
||||
}
|
||||
}
|
||||
|
||||
function checkMarkers() {
|
||||
_.each(markers, function (marker, id) {
|
||||
var line;
|
||||
marker = markers[id];
|
||||
if (marker.find()) {
|
||||
line = editor.getLineHandle(marker.find().from.line);
|
||||
if (!line.text.match(imageMarkdownRegex)) {
|
||||
removeMarker(id, marker, line);
|
||||
}
|
||||
} else {
|
||||
removeMarker(id, marker);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initMarkers(line) {
|
||||
var isImage = line.text.match(imageMarkdownRegex),
|
||||
hasMarker = line.text.match(markerRegex);
|
||||
|
||||
if (isImage && !hasMarker) {
|
||||
addMarker(line, editor.getLineNumber(line));
|
||||
}
|
||||
}
|
||||
|
||||
// public api
|
||||
_.extend(this, {
|
||||
markers: markers,
|
||||
checkMarkers: checkMarkers,
|
||||
addMarker: addMarker,
|
||||
stripMarkerFromLine: stripMarkerFromLine,
|
||||
getMarkerRegexForId: getMarkerRegexForId
|
||||
});
|
||||
|
||||
// Initialise
|
||||
editor.eachLine(initMarkers);
|
||||
};
|
||||
|
||||
UploadManager = function (editor) {
|
||||
var markerMgr = new MarkerManager(editor);
|
||||
|
||||
function findLine(result_id) {
|
||||
// try to find the right line to replace
|
||||
if (markerMgr.markers.hasOwnProperty(result_id) && markerMgr.markers[result_id].find()) {
|
||||
return editor.getLineHandle(markerMgr.markers[result_id].find().from.line);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function checkLine(ln, mode) {
|
||||
var line = editor.getLineHandle(ln),
|
||||
isImage = line.text.match(imageMarkdownRegex),
|
||||
hasMarker;
|
||||
|
||||
// We care if it is an image
|
||||
if (isImage) {
|
||||
hasMarker = line.text.match(markerRegex);
|
||||
|
||||
if (hasMarker && mode === 'paste') {
|
||||
// this could be a duplicate, and won't be a real marker
|
||||
markerMgr.stripMarkerFromLine(line);
|
||||
}
|
||||
|
||||
if (!hasMarker) {
|
||||
markerMgr.addMarker(line, ln);
|
||||
}
|
||||
}
|
||||
// TODO: hasMarker but no image?
|
||||
}
|
||||
|
||||
function handleUpload(e, result_src) {
|
||||
/*jslint regexp: true, bitwise: true */
|
||||
var line = findLine($(e.currentTarget).attr('id')),
|
||||
lineNumber = editor.getLineNumber(line),
|
||||
match = line.text.match(/\([^\n]*\)?/),
|
||||
replacement = '(http://)';
|
||||
/*jslint regexp: false, bitwise: false */
|
||||
|
||||
if (match) {
|
||||
// simple case, we have the parenthesis
|
||||
editor.setSelection({line: lineNumber, ch: match.index + 1}, {line: lineNumber, ch: match.index + match[0].length - 1});
|
||||
} else {
|
||||
match = line.text.match(/\]/);
|
||||
if (match) {
|
||||
editor.replaceRange(
|
||||
replacement,
|
||||
{line: lineNumber, ch: match.index + 1},
|
||||
{line: lineNumber, ch: match.index + 1}
|
||||
);
|
||||
editor.setSelection(
|
||||
{line: lineNumber, ch: match.index + 2},
|
||||
{line: lineNumber, ch: match.index + replacement.length }
|
||||
);
|
||||
}
|
||||
}
|
||||
editor.replaceSelection(result_src);
|
||||
}
|
||||
|
||||
function getEditorValue() {
|
||||
var value = editor.getValue();
|
||||
|
||||
_.each(markerMgr.markers, function (marker, id) {
|
||||
/*jshint unused:false*/
|
||||
value = value.replace(markerMgr.getMarkerRegexForId(id), '');
|
||||
});
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
|
||||
// Public API
|
||||
_.extend(this, {
|
||||
getEditorValue: getEditorValue,
|
||||
handleUpload: handleUpload
|
||||
});
|
||||
|
||||
// initialise
|
||||
editor.on('change', function (cm, changeObj) {
|
||||
/*jshint unused:false*/
|
||||
var linesChanged = _.range(changeObj.from.line, changeObj.from.line + changeObj.text.length);
|
||||
|
||||
_.each(linesChanged, function (ln) {
|
||||
checkLine(ln, changeObj.origin);
|
||||
});
|
||||
|
||||
// Is this a line which may have had a marker on it?
|
||||
markerMgr.checkMarkers();
|
||||
});
|
||||
};
|
||||
|
||||
}());
|
||||
}());
|
Loading…
Add table
Reference in a new issue