From 665bacf4c68db70b01b4e56acb82bf6e03f698cb Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Fri, 14 Mar 2014 13:58:34 +0000 Subject: [PATCH] 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 --- ghost/admin/assets/lib/editor/htmlPreview.js | 44 + ghost/admin/assets/lib/editor/index.js | 79 ++ .../admin/assets/lib/editor/markdownEditor.js | 92 +++ .../admin/assets/lib/editor/markerManager.js | 147 ++++ .../admin/assets/lib/editor/scrollHandler.js | 43 + .../admin/assets/lib/editor/uploadManager.js | 153 ++++ ghost/admin/markdown-actions.js | 14 +- ghost/admin/views/editor-actions-widget.js | 255 ++++++ ghost/admin/views/editor.js | 753 +++--------------- 9 files changed, 909 insertions(+), 671 deletions(-) create mode 100644 ghost/admin/assets/lib/editor/htmlPreview.js create mode 100644 ghost/admin/assets/lib/editor/index.js create mode 100644 ghost/admin/assets/lib/editor/markdownEditor.js create mode 100644 ghost/admin/assets/lib/editor/markerManager.js create mode 100644 ghost/admin/assets/lib/editor/scrollHandler.js create mode 100644 ghost/admin/assets/lib/editor/uploadManager.js create mode 100644 ghost/admin/views/editor-actions-widget.js diff --git a/ghost/admin/assets/lib/editor/htmlPreview.js b/ghost/admin/assets/lib/editor/htmlPreview.js new file mode 100644 index 0000000000..16d1cabb70 --- /dev/null +++ b/ghost/admin/assets/lib/editor/htmlPreview.js @@ -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; +} ()); \ No newline at end of file diff --git a/ghost/admin/assets/lib/editor/index.js b/ghost/admin/assets/lib/editor/index.js new file mode 100644 index 0000000000..cdeac320df --- /dev/null +++ b/ghost/admin/assets/lib/editor/index.js @@ -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; +}()); \ No newline at end of file diff --git a/ghost/admin/assets/lib/editor/markdownEditor.js b/ghost/admin/assets/lib/editor/markdownEditor.js new file mode 100644 index 0000000000..a35f7ab8fd --- /dev/null +++ b/ghost/admin/assets/lib/editor/markdownEditor.js @@ -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; +} ()); \ No newline at end of file diff --git a/ghost/admin/assets/lib/editor/markerManager.js b/ghost/admin/assets/lib/editor/markerManager.js new file mode 100644 index 0000000000..aef4cc02bf --- /dev/null +++ b/ghost/admin/assets/lib/editor/markerManager.js @@ -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; +}()); \ No newline at end of file diff --git a/ghost/admin/assets/lib/editor/scrollHandler.js b/ghost/admin/assets/lib/editor/scrollHandler.js new file mode 100644 index 0000000000..b996879240 --- /dev/null +++ b/ghost/admin/assets/lib/editor/scrollHandler.js @@ -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; +} ()); \ No newline at end of file diff --git a/ghost/admin/assets/lib/editor/uploadManager.js b/ghost/admin/assets/lib/editor/uploadManager.js new file mode 100644 index 0000000000..b83b37fb64 --- /dev/null +++ b/ghost/admin/assets/lib/editor/uploadManager.js @@ -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; +}()); \ No newline at end of file diff --git a/ghost/admin/markdown-actions.js b/ghost/admin/markdown-actions.js index 0d62700037..176fcf81c4 100644 --- a/ghost/admin/markdown-actions.js +++ b/ghost/admin/markdown-actions.js @@ -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' } }; diff --git a/ghost/admin/views/editor-actions-widget.js b/ghost/admin/views/editor-actions-widget.js new file mode 100644 index 0000000000..38fa4a27ef --- /dev/null +++ b/ghost/admin/views/editor-actions-widget.js @@ -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); + } + + }); +}()); \ No newline at end of file diff --git a/ghost/admin/views/editor.js b/ghost/admin/views/editor.js index 35b29605c6..bf8a8b98d9 100644 --- a/ghost/admin/views/editor.js +++ b/ghost/admin/views/editor.js @@ -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(); - }); - }; - -}()); +}()); \ No newline at end of file