From 85f0c71d82558aca1455a6df8a51c3f32a18878e Mon Sep 17 00:00:00 2001 From: Declan Cook Date: Mon, 17 Mar 2014 00:20:01 +0000 Subject: [PATCH 01/16] Fix scoping issue on signup closes #2429 --- ghost/admin/views/login.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ghost/admin/views/login.js b/ghost/admin/views/login.js index d4a8886d57..24e5f9cc77 100644 --- a/ghost/admin/views/login.js +++ b/ghost/admin/views/login.js @@ -94,7 +94,8 @@ var name = this.$('.name').val(), email = this.$('.email').val(), password = this.$('.password').val(), - validationErrors = []; + validationErrors = [], + self = this; if (!validator.isLength(name, 1)) { validationErrors.push("Please enter a name."); @@ -131,7 +132,7 @@ window.location.href = msg.redirect; }, error: function (xhr) { - this.submitted = "no"; + self.submitted = "no"; Ghost.notifications.clearEverything(); Ghost.notifications.addItem({ type: 'error', From 665bacf4c68db70b01b4e56acb82bf6e03f698cb Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Fri, 14 Mar 2014 13:58:34 +0000 Subject: [PATCH 02/16] 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 From c0c5058eea4106eca48ac813be82a744634e998c Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Sun, 16 Mar 2014 12:28:12 +0000 Subject: [PATCH 03/16] Upgrade CodeMirror closes #2108 - upgrade to 4.0.1 - requires removing the deprecated setLine method --- ghost/admin/assets/lib/editor/markerManager.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/ghost/admin/assets/lib/editor/markerManager.js b/ghost/admin/assets/lib/editor/markerManager.js index aef4cc02bf..1b3489d061 100644 --- a/ghost/admin/assets/lib/editor/markerManager.js +++ b/ghost/admin/assets/lib/editor/markerManager.js @@ -45,8 +45,15 @@ // ln - line number addMarker = function (line, ln) { var marker, - magicId = '{<' + uploadId + '>}'; - editor.setLine(ln, magicId + line.text); + magicId = '{<' + uploadId + '>}', + newText = magicId + line.text; + + editor.replaceRange( + newText, + {line: ln, ch: 0}, + {line: ln, ch: newText.length} + ); + marker = editor.markText( {line: ln, ch: 0}, {line: ln, ch: (magicId.length)}, From 545fc6e9114a317696194005e53f8ef1dae30ee6 Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Sun, 16 Mar 2014 16:27:32 +0000 Subject: [PATCH 04/16] Fix undo bug issue #2436 --- ghost/admin/assets/lib/editor/uploadManager.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghost/admin/assets/lib/editor/uploadManager.js b/ghost/admin/assets/lib/editor/uploadManager.js index b83b37fb64..7bbee4e14c 100644 --- a/ghost/admin/assets/lib/editor/uploadManager.js +++ b/ghost/admin/assets/lib/editor/uploadManager.js @@ -61,7 +61,7 @@ if (isImage) { hasMarker = line.text.match(markerRegex); - if (hasMarker && mode === 'paste') { + if (hasMarker && (mode === 'paste' || mode === 'undo')) { // this could be a duplicate, and won't be a real marker markerMgr.stripMarkerFromLine(line); } From 72b4e3bf4d2181a4ac19f63471790da499f6c723 Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Mon, 17 Mar 2014 22:56:50 +0000 Subject: [PATCH 05/16] Force preview to scroll to the end fixes #958, fixes #535 - If the cursor is within the last 5 lines, then scroll to the end of the preview window, rather than using a ratio --- ghost/admin/assets/lib/editor/markdownEditor.js | 3 +++ ghost/admin/assets/lib/editor/scrollHandler.js | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/ghost/admin/assets/lib/editor/markdownEditor.js b/ghost/admin/assets/lib/editor/markdownEditor.js index a35f7ab8fd..65a864e948 100644 --- a/ghost/admin/assets/lib/editor/markdownEditor.js +++ b/ghost/admin/assets/lib/editor/markdownEditor.js @@ -81,6 +81,9 @@ $(document).trigger('markdownEditorChange'); }); }, + isCursorAtEnd: function () { + return codemirror.getCursor('end').line > codemirror.lineCount() - 5; + }, value: function () { return codemirror.getValue(); } diff --git a/ghost/admin/assets/lib/editor/scrollHandler.js b/ghost/admin/assets/lib/editor/scrollHandler.js index b996879240..05d6638cd6 100644 --- a/ghost/admin/assets/lib/editor/scrollHandler.js +++ b/ghost/admin/assets/lib/editor/scrollHandler.js @@ -21,6 +21,10 @@ ratio = previewHeight / markdownHeight, previewPosition = $markdownViewPort.scrollTop() * ratio; + if (markdown.isCursorAtEnd()) { + previewPosition = previewHeight + 30; + } + // apply new scroll $previewViewPort.scrollTop(previewPosition); }, 10); From b13b5a925b75e33dff27af87fd55024c3b46b1b9 Mon Sep 17 00:00:00 2001 From: Sebastian Gierlinger Date: Tue, 18 Mar 2014 14:00:33 +0100 Subject: [PATCH 06/16] Rename getSlug to slug another 2 % of #2124 - renamed `/ghost/api/v0.1/posts/getSlug/ to `/ghost/api/v0.1/posts/slug/` - renamed method getSlug to generateSlug --- ghost/admin/views/post-settings.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghost/admin/views/post-settings.js b/ghost/admin/views/post-settings.js index 397df86eaf..1139b080fe 100644 --- a/ghost/admin/views/post-settings.js +++ b/ghost/admin/views/post-settings.js @@ -76,7 +76,7 @@ // and then update the placeholder value. if (title) { $.ajax({ - url: Ghost.paths.apiRoot + '/posts/getSlug/' + encodeURIComponent(title) + '/', + url: Ghost.paths.apiRoot + '/posts/slug/' + encodeURIComponent(title) + '/', success: function (result) { $postSettingSlugEl.attr('placeholder', result); } From dac8eac10162b39f5a15e649b94e264e306ec6ca Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Mon, 17 Mar 2014 22:01:29 +0000 Subject: [PATCH 07/16] Add shim for codemirror on touchscreens fixes #2385 - stolen the CM shim from js-bin - if we're on a touchscreen device, don't use CM - if we're on a touchscreen device, show a coming soon message for uploads --- .../assets/lib/editor/mobileCodeMirror.js | 112 ++++++++++++++++++ .../lib/showdown/extensions/ghostdown.js | 20 +++- 2 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 ghost/admin/assets/lib/editor/mobileCodeMirror.js diff --git a/ghost/admin/assets/lib/editor/mobileCodeMirror.js b/ghost/admin/assets/lib/editor/mobileCodeMirror.js new file mode 100644 index 0000000000..7e2d3faad5 --- /dev/null +++ b/ghost/admin/assets/lib/editor/mobileCodeMirror.js @@ -0,0 +1,112 @@ +// Taken from js-bin with thanks to Remy Sharp +// yeah, nasty, but it allows me to switch from a RTF to plain text if we're running a iOS + +/*global Ghost, $, _, DocumentTouch, CodeMirror*/ +(function () { + Ghost.touchEditor = false; + + var noop = function () {}, + hasTouchScreen, + smallScreen, + TouchEditor, + _oldCM, + key; + + // Taken from "Responsive design & the Guardian" with thanks to Matt Andrews + // Added !window._phantom so that the functional tests run as though this is not a touch screen. + // In future we can do something more advanced here for testing both touch and non touch + hasTouchScreen = function () { + return !window._phantom && + ( + ('ontouchstart' in window) || + (window.DocumentTouch && document instanceof DocumentTouch) + ); + }; + + smallScreen = function () { + if (window.matchMedia('(max-width: 1000px)').matches) { + return true; + } + + return false; + }; + + if (hasTouchScreen()) { + $('body').addClass('touch-editor'); + Ghost.touchEditor = true; + + TouchEditor = function (el, options) { + /*jshint unused:false*/ + this.textarea = el; + this.win = { document : this.textarea }; + this.ready = true; + this.wrapping = document.createElement('div'); + + var textareaParent = this.textarea.parentNode; + this.wrapping.appendChild(this.textarea); + textareaParent.appendChild(this.wrapping); + + this.textarea.style.opacity = 1; + + $(this.textarea).blur(_.throttle(function () { + $(document).trigger('markdownEditorChange', { panelId: el.id }); + }, 200)); + + if (!smallScreen()) { + $(this.textarea).on('change', _.throttle(function () { + $(document).trigger('markdownEditorChange', { panelId: el.id }); + }, 200)); + } + }; + + TouchEditor.prototype = { + setOption: function (type, handler) { + if (type === 'onChange') { + $(this.textarea).change(handler); + } + }, + eachLine: function () { + return []; + }, + getValue: function () { + return this.textarea.value; + }, + setValue: function (code) { + this.textarea.value = code; + }, + focus: noop, + getCursor: function () { + return { line: 0, ch: 0 }; + }, + setCursor: noop, + currentLine: function () { + return 0; + }, + cursorPosition: function () { + return { character: 0 }; + }, + addMarkdown: noop, + nthLine: noop, + refresh: noop, + selectLines: noop, + on: noop + }; + + _oldCM = CodeMirror; + + // CodeMirror = noop; + + for (key in _oldCM) { + if (_oldCM.hasOwnProperty(key)) { + CodeMirror[key] = noop; + } + } + + CodeMirror.fromTextArea = function (el, options) { + return new TouchEditor(el, options); + }; + + CodeMirror.keyMap = { basic: {} }; + + } +}()); \ No newline at end of file diff --git a/ghost/admin/assets/lib/showdown/extensions/ghostdown.js b/ghost/admin/assets/lib/showdown/extensions/ghostdown.js index c684dd2d6f..9c6fb9cebf 100644 --- a/ghost/admin/assets/lib/showdown/extensions/ghostdown.js +++ b/ghost/admin/assets/lib/showdown/extensions/ghostdown.js @@ -1,4 +1,5 @@ /* jshint node:true, browser:true */ +var Ghost = Ghost || {}; (function () { var ghostdown = function () { return [ @@ -12,15 +13,24 @@ pathRegex = /^(\/)?([^\/\0]+(\/)?)+$/i; return text.replace(imageMarkdownRegex, function (match, key, alt, src) { - var result = ""; + var result = '', + output; if (src && (src.match(uriRegex) || src.match(pathRegex))) { result = ''; } - return '
' + result + - '
Add image of ' + alt + '
' + - '' + - '
'; + + if (Ghost && Ghost.touchEditor) { + output = '
' + + result + '
Mobile uploads coming soon
'; + } else { + output = '
' + + result + '
Add image of ' + alt + '
' + + '' + + '
'; + } + + return output; }); } }, From e4ff9643a9f113868468c85f27e9bd24ea87f788 Mon Sep 17 00:00:00 2001 From: nicksahler Date: Wed, 19 Mar 2014 08:07:49 -0400 Subject: [PATCH 08/16] Fixes modal fading inconsistency problem by disabling jQuery's transition and allowing CSS to do its thing closes #2400 - Removed fadeIn, jQuery's animation - Added show(), which only manages display, giving transitions to CSS --- ghost/admin/views/base.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghost/admin/views/base.js b/ghost/admin/views/base.js index b72a99b3e9..3046f0d256 100644 --- a/ghost/admin/views/base.js +++ b/ghost/admin/views/base.js @@ -316,7 +316,7 @@ }, afterRender: function () { this.$el.fadeIn(50); - $(".modal-background").fadeIn(10, function () { + $(".modal-background").show(10, function () { $(this).addClass("in"); }); if (this.model.options.confirm) { From 4c3bb83df03523eb45e0bbf134c9addaebb9bcae Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Thu, 20 Mar 2014 12:19:52 +0000 Subject: [PATCH 09/16] Removing typography extension issue #2312 - The typography extension is still interfering in HTML blocks, reference style links and other bits and pieces it probably shouldn't be :( - We'll add it back when it's ready. --- ghost/admin/assets/lib/editor/htmlPreview.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ghost/admin/assets/lib/editor/htmlPreview.js b/ghost/admin/assets/lib/editor/htmlPreview.js index 16d1cabb70..af4bb22ac6 100644 --- a/ghost/admin/assets/lib/editor/htmlPreview.js +++ b/ghost/admin/assets/lib/editor/htmlPreview.js @@ -9,7 +9,7 @@ 'use strict'; var HTMLPreview = function (markdown, uploadMgr) { - var converter = new Showdown.converter({extensions: ['typography', 'ghostdown', 'github']}), + var converter = new Showdown.converter({extensions: ['ghostdown', 'github']}), preview = document.getElementsByClassName('rendered-markdown')[0], update; From badd4a0655d8fd964e27eed326bb90568b83754f Mon Sep 17 00:00:00 2001 From: Fabian Becker Date: Sat, 8 Feb 2014 12:56:55 +0100 Subject: [PATCH 10/16] Properly display escaped tags in editor. fixes #2149, fixes #2453 - Escape tag before displaying in editor tag widget --- ghost/admin/views/editor-tag-widget.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/ghost/admin/views/editor-tag-widget.js b/ghost/admin/views/editor-tag-widget.js index aa12f468ed..901cf3b7f0 100644 --- a/ghost/admin/views/editor-tag-widget.js +++ b/ghost/admin/views/editor-tag-widget.js @@ -45,7 +45,7 @@ if (tags) { _.forEach(tags, function (tag) { - var $tag = $('' + tag.name + ''); + var $tag = $('' + _.escape(tag.name) + ''); $tags.append($tag); $("[data-tag-id=" + tag.id + "]")[0].scrollIntoView(true); }); @@ -120,11 +120,14 @@ _.each(matchingTags, function (matchingTag) { var highlightedName, suggestionHTML; - - highlightedName = matchingTag.name.replace(regexPattern, "$1"); + highlightedName = matchingTag.name.replace(regexPattern, function (match, p1) { + return "" + _.escape(p1) + ""; + }); /*jslint regexp: true */ // - would like to remove this - highlightedName = highlightedName.replace(/([^<>]*)((<[^>]+>)+)([^<>]*<\/mark>)/, "$1$2$4"); - + highlightedName = highlightedName.replace(/([^<>]*)((<[^>]+>)+)([^<>]*<\/mark>)/, function (match, p1, p2, p3, p4) { + return _.escape(p1) + '' + _.escape(p2) + '' + _.escape(p4); + }); + suggestionHTML = "
  • " + highlightedName + "
  • "; this.$suggestions.append(suggestionHTML); }, this); @@ -277,7 +280,7 @@ }, addTag: function (tag) { - var $tag = $('' + tag.name + ''); + var $tag = $('' + _.escape(tag.name) + ''); this.$('.tags').append($tag); $(".tag").last()[0].scrollIntoView(true); window.scrollTo(0, 1); From 2393042c76553bd570bffbcadc5e00ca7172b04a Mon Sep 17 00:00:00 2001 From: Hannah Wolfe Date: Thu, 20 Mar 2014 13:52:16 +0000 Subject: [PATCH 11/16] Improving the showdown extensions fixes #2381 - renamed the ghost extensions - added new html tests --- ghost/admin/assets/lib/editor/htmlPreview.js | 2 +- .../lib/showdown/extensions/ghostdown.js | 58 ------------------- 2 files changed, 1 insertion(+), 59 deletions(-) delete mode 100644 ghost/admin/assets/lib/showdown/extensions/ghostdown.js diff --git a/ghost/admin/assets/lib/editor/htmlPreview.js b/ghost/admin/assets/lib/editor/htmlPreview.js index af4bb22ac6..52f6d7239d 100644 --- a/ghost/admin/assets/lib/editor/htmlPreview.js +++ b/ghost/admin/assets/lib/editor/htmlPreview.js @@ -9,7 +9,7 @@ 'use strict'; var HTMLPreview = function (markdown, uploadMgr) { - var converter = new Showdown.converter({extensions: ['ghostdown', 'github']}), + var converter = new Showdown.converter({extensions: ['ghostimagepreview', 'ghostgfm']}), preview = document.getElementsByClassName('rendered-markdown')[0], update; diff --git a/ghost/admin/assets/lib/showdown/extensions/ghostdown.js b/ghost/admin/assets/lib/showdown/extensions/ghostdown.js deleted file mode 100644 index 9c6fb9cebf..0000000000 --- a/ghost/admin/assets/lib/showdown/extensions/ghostdown.js +++ /dev/null @@ -1,58 +0,0 @@ -/* jshint node:true, browser:true */ -var Ghost = Ghost || {}; -(function () { - var ghostdown = function () { - return [ - // ![] image syntax - { - type: 'lang', - filter: function (text) { - var imageMarkdownRegex = /^(?:\{<(.*?)>\})?!(?:\[([^\n\]]*)\])(?:\(([^\n\]]*)\))?$/gim, - /* regex from isURL in node-validator. Yum! */ - uriRegex = /^(?!mailto:)(?:(?:https?|ftp):\/\/)?(?:\S+(?::\S*)?@)?(?:(?:(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[0-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]+-?)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]+-?)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))|localhost)(?::\d{2,5})?(?:\/[^\s]*)?$/i, - pathRegex = /^(\/)?([^\/\0]+(\/)?)+$/i; - - return text.replace(imageMarkdownRegex, function (match, key, alt, src) { - var result = '', - output; - - if (src && (src.match(uriRegex) || src.match(pathRegex))) { - result = ''; - } - - if (Ghost && Ghost.touchEditor) { - output = '
    ' + - result + '
    Mobile uploads coming soon
    '; - } else { - output = '
    ' + - result + '
    Add image of ' + alt + '
    ' + - '' + - '
    '; - } - - return output; - }); - } - }, - - // 4 or more inline underscores e.g. Ghost rocks my _____! - { - type: 'lang', - filter: function (text) { - return text.replace(/([^_\n\r])(_{4,})/g, function (match, prefix, underscores) { - return prefix + underscores.replace(/_/g, '_'); - }); - } - } - ]; - }; - - // Client-side export - if (typeof window !== 'undefined' && window.Showdown && window.Showdown.extensions) { - window.Showdown.extensions.ghostdown = ghostdown; - } - // Server-side export - if (typeof module !== 'undefined') { - module.exports = ghostdown; - } -}()); From f4f4dc08e35ca1b2605b9f465dbc917a8498cf60 Mon Sep 17 00:00:00 2001 From: Lucas Churchill Date: Sun, 23 Mar 2014 00:27:53 -0300 Subject: [PATCH 12/16] Adding event preventDefault on toggleFeatured function --- ghost/admin/views/blog.js | 1 + 1 file changed, 1 insertion(+) diff --git a/ghost/admin/views/blog.js b/ghost/admin/views/blog.js index a6256bc438..8788d5b2bb 100644 --- a/ghost/admin/views/blog.js +++ b/ghost/admin/views/blog.js @@ -228,6 +228,7 @@ }, toggleFeatured: function (e) { + e.preventDefault(); var self = this, featured = !self.model.get('featured'), featuredEl = $(e.currentTarget), From dbf6da54e82ded675f354025a713ff896bc8735e Mon Sep 17 00:00:00 2001 From: cobbspur Date: Sun, 23 Mar 2014 17:24:27 +0000 Subject: [PATCH 13/16] clears notifications on clicking featured icon closes #2479 - adds a cleareverything command to toggleFeatured in blog.js to stop stacking of notifications on multiple clicks of featured icon --- ghost/admin/views/blog.js | 1 + 1 file changed, 1 insertion(+) diff --git a/ghost/admin/views/blog.js b/ghost/admin/views/blog.js index 8788d5b2bb..f80b8efd25 100644 --- a/ghost/admin/views/blog.js +++ b/ghost/admin/views/blog.js @@ -239,6 +239,7 @@ }, { success : function () { featuredEl.removeClass("featured unfeatured").addClass(featured ? "featured" : "unfeatured"); + Ghost.notifications.clearEverything(); Ghost.notifications.addItem({ type: 'success', message: "Post successfully marked as " + (featured ? "featured" : "not featured") + ".", From 8881656c262ed6ff086ec1f5daead700c15190c0 Mon Sep 17 00:00:00 2001 From: Fabian Becker Date: Mon, 24 Mar 2014 10:35:54 +0000 Subject: [PATCH 14/16] Prevent adding duplicate tags with different casing fixes #2478 - Check for existing tags with different case --- ghost/admin/views/editor-tag-widget.js | 33 +++++++++++++++----------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/ghost/admin/views/editor-tag-widget.js b/ghost/admin/views/editor-tag-widget.js index 901cf3b7f0..3c157467b2 100644 --- a/ghost/admin/views/editor-tag-widget.js +++ b/ghost/admin/views/editor-tag-widget.js @@ -188,7 +188,8 @@ searchTerm = $.trim($target.val()), tag, $selectedSuggestion, - isComma = ",".localeCompare(String.fromCharCode(e.keyCode || e.charCode)) === 0; + isComma = ",".localeCompare(String.fromCharCode(e.keyCode || e.charCode)) === 0, + hasAlreadyBeenAdded; // use localeCompare in case of international keyboard layout if ((e.keyCode === this.keys.ENTER || isComma) && searchTerm) { @@ -197,16 +198,19 @@ $selectedSuggestion = this.$suggestions.children(".selected"); if (this.$suggestions.is(":visible") && $selectedSuggestion.length !== 0) { - - if ($('.tag:containsExact("' + _.unescape($selectedSuggestion.data('tag-name')) + '")').length === 0) { - tag = {id: $selectedSuggestion.data('tag-id'), name: _.unescape($selectedSuggestion.data('tag-name'))}; + tag = {id: $selectedSuggestion.data('tag-id'), name: _.unescape($selectedSuggestion.data('tag-name'))}; + hasAlreadyBeenAdded = this.hasTagBeenAdded(tag.name); + if (!hasAlreadyBeenAdded) { this.addTag(tag); } } else { if (isComma) { + // Remove comma from string if comma is used to submit. searchTerm = searchTerm.replace(/,/g, ""); - } // Remove comma from string if comma is used to submit. - if ($('.tag:containsExact("' + searchTerm + '")').length === 0) { + } + + hasAlreadyBeenAdded = this.hasTagBeenAdded(searchTerm); + if (!hasAlreadyBeenAdded) { this.addTag({id: null, name: searchTerm}); } } @@ -219,13 +223,9 @@ completeCurrentTag: function () { var $target = this.$('.tag-input'), tagName = $target.val(), - usedTagNames, hasAlreadyBeenAdded; - usedTagNames = _.map(this.model.get('tags'), function (tag) { - return tag.name.toUpperCase(); - }); - hasAlreadyBeenAdded = usedTagNames.indexOf(tagName.toUpperCase()) !== -1; + hasAlreadyBeenAdded = this.hasTagBeenAdded(tagName); if (tagName.length > 0 && !hasAlreadyBeenAdded) { this.addTag({id: null, name: tagName}); @@ -270,9 +270,8 @@ tagNameMatches = tag.name.toUpperCase().indexOf(searchTerm) !== -1; - hasAlreadyBeenAdded = _.some(self.model.get('tags'), function (usedTag) { - return tag.name.toUpperCase() === usedTag.name.toUpperCase(); - }); + hasAlreadyBeenAdded = self.hasTagBeenAdded(tag.name); + return tagNameMatches && !hasAlreadyBeenAdded; }); @@ -288,6 +287,12 @@ this.$('.tag-input').val('').focus(); this.$suggestions.hide(); + }, + + hasTagBeenAdded: function (tagName) { + return _.some(this.model.get('tags'), function (usedTag) { + return tagName.toUpperCase() === usedTag.name.toUpperCase(); + }); } }); From 8e6d484adab430e9740118dd34fceda9951cb8d0 Mon Sep 17 00:00:00 2001 From: Fabian Becker Date: Mon, 24 Mar 2014 12:03:05 +0000 Subject: [PATCH 15/16] Escape regex special characters in tag finder refs #2149 - Properly highlight tags with special characters ($,[,],^,etc.) --- ghost/admin/views/editor-tag-widget.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/ghost/admin/views/editor-tag-widget.js b/ghost/admin/views/editor-tag-widget.js index 901cf3b7f0..7dc4cc40a5 100644 --- a/ghost/admin/views/editor-tag-widget.js +++ b/ghost/admin/views/editor-tag-widget.js @@ -106,8 +106,11 @@ styles = { left: $target.position().left }, - maxSuggestions = 5, // Limit the suggestions number - regexTerm = searchTerm.replace(/(\s+)/g, "(<[^>]+>)*$1(<[^>]+>)*"), + // Limit the suggestions number + maxSuggestions = 5, + // Escape regex special characters + escapedTerm = searchTerm.replace(/[\-\/\\\^$*+?.()|\[\]{}]/g, '\\$&'), + regexTerm = escapedTerm.replace(/(\s+)/g, "(<[^>]+>)*$1(<[^>]+>)*"), regexPattern = new RegExp("(" + regexTerm + ")", "i"); this.$suggestions.css(styles); @@ -120,6 +123,7 @@ _.each(matchingTags, function (matchingTag) { var highlightedName, suggestionHTML; + highlightedName = matchingTag.name.replace(regexPattern, function (match, p1) { return "" + _.escape(p1) + ""; }); From 12b801444128b3589bf648a4c812b59c3f05bb61 Mon Sep 17 00:00:00 2001 From: Sebastian Gierlinger Date: Sun, 6 Apr 2014 17:46:04 +0200 Subject: [PATCH 16/16] Remove second PUT request on image save closes #2557 - replaced model.save() with model.set() --- ghost/admin/views/settings.js | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/ghost/admin/views/settings.js b/ghost/admin/views/settings.js index db22ca3d2a..017a3944dc 100644 --- a/ghost/admin/views/settings.js +++ b/ghost/admin/views/settings.js @@ -220,14 +220,8 @@ } else { data[key] = this.$('.js-upload-target').attr('src'); } - - self.model.save(data, { - success: self.saveSuccess, - error: self.saveError - }).then(function () { - self.saveSettings(); - }); - + self.model.set(data); + self.saveSettings(); return true; }, buttonClass: "button-save right", @@ -298,12 +292,8 @@ } else { data[key] = this.$('.js-upload-target').attr('src'); } - self.model.save(data, { - success: self.saveSuccess, - error: self.saveError - }).then(function () { - self.saveUser(); - }); + self.model.set(data); + self.saveUser(data); return true; }, buttonClass: "button-save right",