diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..df33375 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +### About +Etch is a content editor built on Backbone.js and is designed to be easily plugged into your Backbone app or stand alone. + +### Include Dependencies + +Etch depends on [jQuery](http://jquery.com), [Underscore](http://underscorejs.com), [Backbone](http://backbonejs.com), as well as [Rangy](http://code.google.com/p/rangy/) if you need support for legacy browsers (IE8 and prior). + +_You can exclude Rangy and shave 41k off of your footprint if you don't need legacy support._ + +Once you have the dependencies squared away simply include the etch.js script after them and before the script where you define your Models/Views. + +At this point your scripts section should look something like this: + +``` + + + + + + + + + + +``` + +_Ensure that your scripts are in the right order or everything will likely be broken._ + +Also you need to add etch.css to your stylesheets +``` + +``` + +### Building The Model + +This part is simple, we just define a model and give it a url and some defaults. Your model will probably end up being more complex but this is all it takes to get Etch working. + +``` + var article = Backbone.Model.extend({ + url: '/some/api/url/', + + defaults: { + title: 'Default Title', + body: 'Default body text' + } + }); +``` + +### Building The View + +Basically all we need to do is call `etch.editableInit` when a user clicks (mousedown) on an editable element. Because of how backbone delegates events we need to call an intermediate function, `editableClick`, which references `etch.editableInit`. + +``` + var articleView = Backbone.View.extend({ + events: { + 'mousedown .editable': 'editableClick' + }, + + editableClick: etch.editableInit + }); +``` + +etch.editableInit handles everything else for you except for saving. Etch will trigger a 'save' event on your model when the save button is clicked. All we need to do is listen for it by adding a binding to the view like so: + +``` + var articleView = Backbone.View.extend({ + initialize: function() { + _.bindAll(this, 'save'); + this.model.bind('save', this.model.save); + }, + + events: { + 'mousedown .editable': 'editableClick' + }, + + editableClick: etch.editableInit + }); +``` + +### Customizing + +You may have noticed that the demo had different buttons in the editor widget depending on if you were editing the body or the title. Etch allows you to customize which buttons to show on a given 'editable' by adding a `data-button-class` attribute to the element. + +The default classes are: + +``` + etch.config.buttonClasses = { + 'default': ['save'], + 'all': ['bold', 'italic', 'underline', 'unordered-list', 'ordered-list', 'link', 'clear-formatting', 'save'], + 'title': ['bold', 'italic', 'underline', 'save'] + }; +``` + +_The 'default' button class will be used if no button class is defined on the element._ + +Defining your own button classes can be accomplished by extending `etch.config.buttonClasses`. Here we override 'default' to add more buttons and add a 'caption' class. +``` + _.extend(etch.config.buttonClasses, { + 'default': ['bold', 'italic', 'underline', 'save'], + 'caption': ['bold', 'italic', 'underline', 'link', 'save'] + }); +``` + +_The order of buttons in the array is how they will be presented in the editor widget._ + +If the class '.editable' causes conflicts for you or you need to change it for any reason you can do so by setting `etch.config.selector`. + +``` + etch.config.selector = '.my-new-editable-class'; +``` + +All functions are public and can be overridden to customize functionality + +For instance, if you want to create a custom popup for the link url prompt: + +``` + etch.views.Editor.prototype.urlPrompt = function(callback) { + // Custom popup code to get url + callback(url) + } +``` diff --git a/demo/demo.html b/demo/demo.html new file mode 100644 index 0000000..92ff5e0 --- /dev/null +++ b/demo/demo.html @@ -0,0 +1,64 @@ + + + + Etch.js - Minimal demo + + + + +

Very very basic demo, so developers can play with it while they're developing features.

+

Just click on text below to see Etch in action.

+
+
+

Here is a title

+
+ Text with a light blue background is editable. You will find it easy to use etch to underline text as well as bold and italic. + Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. +
+
+
+ + + + + + + + + diff --git a/license.txt b/license.txt new file mode 100644 index 0000000..eee6fa5 --- /dev/null +++ b/license.txt @@ -0,0 +1,13 @@ +The MIT License (MIT) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files +(the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/scripts/etch.js b/scripts/etch.js new file mode 100644 index 0000000..f596732 --- /dev/null +++ b/scripts/etch.js @@ -0,0 +1,367 @@ +(function() { + 'use strict'; + + var models = {}, + views = {}, + collections = {}, + etch = {}; + + // versioning as per semver.org + etch.VERSION = '0.6.3'; + + etch.config = { + // selector to specify editable elements + selector: '.editable', + + // Named sets of buttons to be specified on the editable element + // in the markup as "data-button-class" + buttonClasses: { + 'default': ['save'], + 'all': ['bold', 'italic', 'underline', 'unordered-list', 'ordered-list', 'link', 'clear-formatting', 'save'], + 'title': ['bold', 'italic', 'underline', 'save'] + } + }; + + models.Editor = Backbone.Model; + + views.Editor = Backbone.View.extend({ + initialize: function() { + this.$el = $(this.el); + + // Model attribute event listeners: + _.bindAll(this, 'changeButtons', 'changePosition', 'changeEditable', 'insertImage'); + this.model.bind('change:buttons', this.changeButtons); + this.model.bind('change:position', this.changePosition); + this.model.bind('change:editable', this.changeEditable); + + // Init Routines: + this.changeEditable(); + }, + + events: { + 'click .etch-bold': 'toggleBold', + 'click .etch-italic': 'toggleItalic', + 'click .etch-underline': 'toggleUnderline', + 'click .etch-heading': 'toggleHeading', + 'click .etch-unordered-list': 'toggleUnorderedList', + 'click .etch-justify-left': 'justifyLeft', + 'click .etch-justify-center': 'justifyCenter', + 'click .etch-justify-right': 'justifyRight', + 'click .etch-ordered-list': 'toggleOrderedList', + 'click .etch-link': 'toggleLink', + 'click .etch-image': 'getImage', + 'click .etch-save': 'save', + 'click .etch-clear-formatting': 'clearFormatting' + }, + + changeEditable: function() { + this.setButtonClass(); + // Im assuming that Ill add more functionality here + }, + + setButtonClass: function() { + // check the button class of the element being edited and set the associated buttons on the model + var editorModel = this.model; + var buttonClass = editorModel.get('editable').attr('data-button-class') || 'default'; + editorModel.set({ buttons: etch.config.buttonClasses[buttonClass] }); + }, + + changeButtons: function() { + // render the buttons into the editor-panel + this.$el.empty(); + var view = this; + var buttons = this.model.get('buttons'); + + // hide editor panel if there are no buttons in it and exit early + if (!buttons.length) { $(this.el).hide(); return; } + + _.each(this.model.get('buttons'), function(button){ + var $buttonEl = $(''); + view.$el.append($buttonEl); + }); + + $(this.el).show('fast'); + }, + + changePosition: function() { + // animate editor-panel to new position + var pos = this.model.get('position'); + this.$el.animate({'top': pos.y, 'left': pos.x}, { queue: false }); + }, + + wrapSelection: function(selectionOrRange, elString, cb) { + // wrap current selection with elString tag + var range = selectionOrRange === Range ? selectionOrRange : selectionOrRange.getRangeAt(0); + var el = document.createElement(elString); + range.surroundContents(el); + }, + + clearFormatting: function(e) { + e.preventDefault(); + document.execCommand('removeFormat', false, null); + }, + + toggleBold: function(e) { + e.preventDefault(); + document.execCommand('bold', false, null); + }, + + toggleItalic: function(e) { + e.preventDefault(); + document.execCommand('italic', false, null); + }, + + toggleUnderline: function(e) { + e.preventDefault(); + document.execCommand('underline', false, null); + }, + + toggleHeading: function(e) { + e.preventDefault(); + var range = window.getSelection().getRangeAt(0); + var wrapper = range.commonAncestorContainer.parentElement + if ($(wrapper).is('h3')) { + $(wrapper).replaceWith(wrapper.textContent) + return; + } + var h3 = document.createElement('h3'); + range.surroundContents(h3); + }, + + urlPrompt: function(callback) { + // This uses the default browser UI prompt to get a url. + // Override this function if you want to implement a custom UI. + + var url = prompt('Enter a url', 'http://'); + + // Ensure a new link URL starts with http:// or https:// + // before it's added to the DOM. + // + // NOTE: This implementation will disallow relative URLs from being added + // but will make it easier for users typing external URLs. + if (/^((http|https)...)/.test(url)) { + callback(url); + } else { + callback("http://" + url); + } + }, + + toggleLink: function(e) { + e.preventDefault(); + var range = window.getSelection().getRangeAt(0); + + // are we in an anchor element? + if (range.startContainer.parentNode.tagName === 'A' || range.endContainer.parentNode.tagName === 'A') { + // unlink anchor + document.execCommand('unlink', false, null); + } else { + // promt for url and create link + this.urlPrompt(function(url) { + document.execCommand('createLink', false, url); + }); + } + }, + + toggleUnorderedList: function(e) { + e.preventDefault(); + document.execCommand('insertUnorderedList', false, null); + }, + + toggleOrderedList: function(e){ + e.preventDefault(); + document.execCommand('insertOrderedList', false, null); + }, + + justifyLeft: function(e) { + e.preventDefault(); + document.execCommand('justifyLeft', false, null); + }, + + justifyCenter: function(e) { + e.preventDefault(); + document.execCommand('justifyCenter', false, null); + }, + + justifyRight: function(e) { + e.preventDefault(); + document.execCommand('justifyRight', false, null); + }, + + getImage: function(e) { + e.preventDefault(); + + // call startUploader with callback to handle inserting it once it is uploaded/cropped + this.startUploader(this.insertImage); + }, + + startUploader: function(cb) { + // initialize Image Uploader + var model = new models.ImageUploader(); + var view = new views.ImageUploader({model: model}); + + // stash a reference to the callback to be called after image is uploaded + model._imageCallback = function(image) { + view.startCropper(image, cb); + }; + + + // stash reference to saved range for inserting the image once its + this._savedRange = window.getSelection().getRangeAt(0); + + // insert uploader html into DOM + $('body').append(view.render().el); + }, + + insertImage: function(image) { + // insert image - passed as a callback to startUploader + var sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(this._savedRange); + + var attrs = { + 'editable': this.model.get('editable'), + 'editableModel': this.model.get('editableModel') + }; + + _.extend(attrs, image); + + var model = new models.EditableImage(attrs); + var view = new views.EditableImage({model: model}); + this._savedRange.insertNode($(view.render().el).addClass('etch-float-left')[0]); + }, + + save: function(e) { + e.preventDefault(); + var editableModel = this.model.get('editableModel'); + editableModel.trigger('save'); + } + }); + + // tack on models, views, etc... as well as init function + _.extend(etch, { + models: models, + views: views, + collections: collections, + + // This function is to be used as callback to whatever event + // you use to initialize editing + editableInit: function(e) { + e.stopPropagation(); + var target = e.target || e.srcElement; + var $editable = $(target).etchFindEditable(); + $editable.attr('contenteditable', true); + + // if the editor isn't already built, build it + var $editor = $('.etch-editor-panel'); + var editorModel = $editor.data('model'); + if (!$editor.size()) { + $editor = $('
'); + var editorAttrs = { editable: $editable, editableModel: this.model }; + document.body.appendChild($editor[0]); + $editor.etchInstantiate({classType: 'Editor', attrs: editorAttrs}); + editorModel = $editor.data('model'); + + // check if we are on a new editable + } else if ($editable[0] !== editorModel.get('editable')[0]) { + // set new editable + editorModel.set({ + editable: $editable, + editableModel: this.model + }); + } + + // Firefox seems to be only browser that defaults to `StyleWithCSS == true` + // so we turn it off here. Plus a try..catch to avoid an error being thrown in IE8. + try { + document.execCommand('StyleWithCSS', false, false); + } + catch (err) { + // expecting to just eat IE8 error, but if different error, rethrow + if (err.message !== "Invalid argument.") { + throw err; + } + } + + if (models.EditableImage) { + // instantiate any images that may be in the editable + var $imgs = $editable.find('img'); + if ($imgs.size()) { + var attrs = { editable: $editable, editableModel: this.model }; + $imgs.each(function() { + var $this = $(this); + if (!$this.data('editableImageModel')) { + var editableImageModel = new models.EditableImage(attrs); + var editableImageView = new views.EditableImage({model: editableImageModel, el: this, tagName: this.tagName}); + $this.data('editableImageModel', editableImageModel); + } + }); + } + } + + // listen for mousedowns that are not coming from the editor + // and close the editor + // unbind first to make sure we aren't doubling up on listeners + $('body').unbind('mousedown.editor').bind('mousedown.editor', function(e) { + // check to see if the click was in an etch tool + var target = e.target || e.srcElement; + if ($(target).not('.etch-editor-panel, .etch-editor-panel *, .etch-image-tools, .etch-image-tools *').size()) { + // remove editor + $editor.remove(); + + + if (models.EditableImage) { + // unblind the image-tools if the editor isn't active + $editable.find('img').unbind('mouseenter'); + + // remove any latent image tool model references + $(etch.config.selector+' img').data('editableImageModel', false) + } + + // once the editor is removed, remove the body binding for it + $(this).unbind('mousedown.editor'); + } + }); + + editorModel.set({position: {x: e.pageX - 15, y: e.pageY - 80}}); + } + }); + + // jquery helper functions + $.fn.etchInstantiate = function(options, cb) { + return this.each(function() { + var $el = $(this); + options || (options = {}); + + var settings = { + el: this, + attrs: {} + } + + _.extend(settings, options); + + var model = new models[settings.classType](settings.attrs, settings); + + // initialize a view is there is one + if (_.isFunction(views[settings.classType])) { + var view = new views[settings.classType]({model: model, el: this, tagName: this.tagName}); + } + + // stash the model and view on the elements data object + $el.data({model: model}); + $el.data({view: view}); + + if (_.isFunction(cb)) { + cb({model: model, view: view}); + } + }); + } + + $.fn.etchFindEditable = function() { + // function that looks for the editable selector on itself or its parents + // and returns that el when it is found + var $el = $(this); + return $el.is(etch.config.selector) ? $el : $el.closest(etch.config.selector); + } + + window.etch = etch; +})(); diff --git a/styles/etch.css b/styles/etch.css new file mode 100644 index 0000000..3efe7ce --- /dev/null +++ b/styles/etch.css @@ -0,0 +1,39 @@ +/* === Editor === */ +.etch-editor-panel { display: none; padding: 5px; position: absolute; z-index: 10000; + background: #dadada; border: 1px solid #bdbdbd; color: #535353; text-shadow: 0 1px 0 #fff; + background-image: -moz-linear-gradient(center bottom, #ccc 0%, #ddd 100%); + background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, from(#ddd), to(#ccc)); + -webkit-border-radius: 3px; -moz-border-radius: 3px; border-radius: 3px; + -webkit-box-shadow: inset 1px 0 0 #dfdfdf, inset 0 1px 0 #dfdfdf, inset -1px 0 0 #dfdfdf, inset 0 -1px 0 #dfdfdf, 0 0 5px rgba(0,0,0,.3); + } +.etch-editor-button { display: block; float: left; margin-right: 3px; position: relative; text-decoration: none; + display: block; width: 27px; height: 27px; background: #dadada; border: 1px solid #aaa; color: #535353; text-shadow: 0 1px 0 #fff; + background: -moz-linear-gradient(center bottom, #e1e1e1 0%, #efefef 100%); + background: -webkit-gradient(linear, 0% 0%, 0% 100%, from(#fFfFfF), to(#f1f1f1)); + -webkit-border-radius: 3px; -moz-border-radius: 3px; border-radius: 3px; + } + +.etch-editor-button:last-child { margin-right: 0; } +.etch-editor-button:hover { background: #fff; border-color: #999; } +.etch-editor-button span:active { box-shadow: inset 0 0 15px rgba(0,0,0,.5); border-color: #999; } + +.etch-editor-button span, +.etch-editor-button span { background: url('images/editor_icons.png') no-repeat; + display: block; height: 100%; width: 100%; +} + +.etch-editor-panel .etch-bold span { background-position: 0 0; } +.etch-editor-panel .etch-italic span { background-position: -30px 0; } +.etch-editor-panel .etch-underline span { background-position: -60px 0; } +.etch-editor-panel .etch-unordered-list span { background-position: -90px 0; } +.etch-editor-panel .etch-ordered-list span { background-position: -120px 0; } +.etch-editor-panel .etch-image span { background-position: -150px 0; } +.etch-editor-panel .etch-link span { background-position: -180px 0; } +.etch-editor-panel .etch-save span { background-position: -210px 0; } +.etch-editor-panel .etch-justify-left span { background-position: -240px 0; } +.etch-editor-panel .etch-justify-center span { background-position: -270px 0; } +.etch-editor-panel .etch-justify-right span { background-position: -300px 0; } +.etch-editor-panel .etch-cite span { background-position: -330px 0; } +.etch-editor-panel .etch-heading span { background-position: -360px 0; } +.etch-editor-panel .etch-clear-formatting span { background-position: -390px 0; } + diff --git a/styles/images/editor_icons.png b/styles/images/editor_icons.png new file mode 100644 index 0000000..1be4292 Binary files /dev/null and b/styles/images/editor_icons.png differ