├── .bowerrc ├── .gitignore ├── Gruntfile.js ├── LICENSE ├── README.md ├── angular-markdown-editable.js ├── bower.json ├── example └── index.html ├── extensions ├── iframe.js └── video.js ├── karma.conf.js ├── package.json └── unit-tests.js /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "lib" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | lib 4 | node_modules 5 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function (grunt) { 2 | 3 | require('load-grunt-tasks')(grunt); 4 | 5 | grunt.initConfig({ 6 | pkg: grunt.file.readJSON('package.json'), 7 | 8 | karma: { 9 | unit: { 10 | configFile: 'karma.conf.js', 11 | singleRun: true 12 | } 13 | } 14 | }); 15 | 16 | grunt.registerTask('test', [ 17 | 'karma' 18 | ]); 19 | }; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Chris 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | angular-markdown-editable 2 | ========================= 3 | 4 | Markdown is awesome. ContentEditable is awesome. How about we display the parsed markdown, but let users edit the base markdown on focus? 5 | 6 | ### Usage 7 | 8 | You'll need to use three directives to make full use of this module. 9 | 10 | ```ng-model``` is required, and ```contenteditable="true"``` should be there to activate both native contentEditable functionality as well as the additional ```contenteditable``` directive included in this module, which wires up ```ngModel``` to ```contenteditable``` changes. 11 | 12 | Finally, add ```markdown-editable``` to have markdown parsed as html in non-focused states and parsed as text in focused states. A typical implementation looks like this: 13 | 14 | ``` 15 |
{{ markdownText }}
16 | ``` 17 | 18 | where ```$scope.markdownText = "# This is an h1. \n## This is an h2. \n- This is a line item\n- This is a second line item\n\nThis is a new paragraph\n\n# Another h1";```... or any valid markdown text that you like. 19 | 20 | ### Target 21 | If you'd like to change the target attribute for links in your markdown, try something like the following: 22 | 23 | ``` 24 |
25 | ``` 26 | 27 | Setting the ```target``` attribute on the markdown-editable directive will copy that attribute to all anchor tags. 28 | 29 | ### Testing 30 | 31 | 1. Install dependencies with ```bower install``` and ```npm install```. 32 | 2. Make sure you have grunt-cli... ```npm install -g grunt-cli```. 33 | 3. Run tests with ```grunt test```. 34 | -------------------------------------------------------------------------------- /angular-markdown-editable.js: -------------------------------------------------------------------------------- 1 | //---------------------------------------------------------------------------------------------------------------------- 2 | // A directive for rendering markdown in AngularJS. 3 | // 4 | // Written by John Lindquist (original author). Modified by Jonathan Rowny (ngModel support). 5 | // Adapted by Christopher S. Case 6 | // Extended even further by Chris Esplin (@deltaepsilon) 7 | // 8 | // 9 | // Taken from: http://blog.angularjs.org/2012/05/custom-components-part-1.html 10 | // 11 | // @module angular.markdown.js 12 | //---------------------------------------------------------------------------------------------------------------------- 13 | 14 | angular.module("angular-markdown-editable", []).directive('markdownEditable', function($timeout) { 15 | var converter = new Showdown.converter(); 16 | 17 | return { 18 | restrict: 'A', 19 | require: '?ngModel', 20 | priority: 1, 21 | link: function postLink(scope, element, attrs, model) { 22 | // Check for extensions 23 | var extAttr = attrs.extensions; 24 | var callPrettyPrint = false; 25 | if(extAttr) { 26 | var extensions = []; 27 | 28 | // Convert the comma separated string into a list. 29 | extAttr.split(',').forEach(function(val) 30 | { 31 | // Strip any whitespace from the beginning or end. 32 | extensions.push(val.replace(/^\s+|\s+$/g, '')); 33 | }); 34 | 35 | if(extensions.indexOf('prettify') >= 0) 36 | { 37 | callPrettyPrint = true; 38 | } // end if 39 | 40 | // Create a new converter. 41 | converter = new Showdown.converter({extensions: extensions}); 42 | } // end if 43 | 44 | // Check for option to strip whitespace 45 | var stripWS = attrs.strip; 46 | if(String(stripWS).toLowerCase() == 'true') { 47 | stripWS = true; 48 | } else { 49 | stripWS = false; 50 | } // end if 51 | 52 | // Check for option to translate line breaks 53 | var lineBreaks = attrs.lineBreaks; 54 | if (String(lineBreaks).toLowerCase() == 'true') { 55 | lineBreaks = true; 56 | } else { 57 | lineBreaks = false; 58 | } // end if 59 | 60 | var render = function() { 61 | var htmlText = ""; 62 | var val = ""; 63 | 64 | // Check to see if we're using a model. 65 | if(attrs.ngModel) { 66 | if (model.$modelValue) { 67 | val = model.$modelValue; 68 | } // end if 69 | } else { 70 | val = element.text(); 71 | } // end if 72 | 73 | if(stripWS) { 74 | val = val.replace(/^[ /t]+/g, '').replace(/\n[ /t]+/g, '\n'); 75 | } // end stripWS 76 | 77 | if (lineBreaks) { 78 | val = val.replace(/ /g, '\n'); 79 | } // end lineBreaks 80 | 81 | // Compile the markdown, and set it. 82 | if (val) { 83 | htmlText = converter.makeHtml(val); 84 | element.html(htmlText); 85 | 86 | if(callPrettyPrint) { 87 | prettyPrint(); 88 | } // end if 89 | } 90 | 91 | if (attrs.target) { 92 | $timeout(function () { 93 | if (element && element[0]) { 94 | var aTags = element[0].querySelectorAll('a'); 95 | var i = aTags.length; 96 | 97 | while (i--) { 98 | aTags[i].setAttribute('target', attrs.target); 99 | } 100 | } 101 | }); 102 | } 103 | }; 104 | 105 | if(attrs.ngModel) { 106 | model.$render = render; 107 | $timeout(render); 108 | } // end if 109 | 110 | //Support for contenteditable 111 | if(attrs.contenteditable === "true" && attrs.ngModel) { 112 | var LINEBREAK_REGEX = /\n/g, 113 | BR_REGEX = /<(br|p|div)(\/)?>/g, 114 | TAG_REGEX = /<.+?>/g, 115 | NBSP_REGEX = / /g, 116 | BLOCKQUOTE_REGEX = />/g, 117 | OPEN_TAG_REGEX = /```</g, 118 | OPEN_TAG_REVERSE_REGEX = /```\"); 131 | text = text.replace(DOUBLE_SPACE_REGEX, "  "); 132 | 133 | element.html(text); 134 | } 135 | 136 | }); 137 | 138 | element.on('blur', function () { 139 | var html = element.html(); 140 | 141 | 142 | html = html.replace(BR_REGEX, "\n"); 143 | html = html.replace(TAG_REGEX, ""); 144 | html = html.replace(NBSP_REGEX, " "); 145 | html = html.replace(OPEN_TAG_REGEX, "```<"); 146 | html = html.replace(OPEN_TAG_NEWLINE_REGEX, "```\n<"); 147 | html = html.replace(BLOCKQUOTE_REGEX, ">"); 148 | html = html.replace(TRIPLE_LINEBREAK_REGEX, "\n\n"); 149 | 150 | // console.log('html', html); 151 | 152 | model.$setViewValue(html); 153 | 154 | $timeout(render); 155 | 156 | }); 157 | } 158 | 159 | render(); 160 | } // end link 161 | } 162 | }); // end markdown directive 163 | 164 | angular.module("angular-markdown-editable").directive('contenteditable', function($timeout) { 165 | return { 166 | require: 'ngModel', 167 | restrict: 'A', 168 | link: function postLink(scope, element, attrs, ctrl) { 169 | var maxLength = parseInt(attrs.ngMaxlength, 10); 170 | 171 | if (window.getSelection) { 172 | element.on('focus', function() { 173 | return $timeout(function() { 174 | var el, range, selection; 175 | selection = window.getSelection(); 176 | range = document.createRange(); 177 | el = element[0]; 178 | 179 | if (el.firstChild) { // Empty elements will throw errors 180 | range.setStart(el.firstChild, 0); 181 | range.setEnd(el.lastChild, el.lastChild.length); 182 | selection.removeAllRanges(); 183 | return selection.addRange(range); 184 | } 185 | 186 | }); 187 | }); 188 | } 189 | element.on('blur', function() { 190 | return scope.$apply(function() { 191 | var value = element.attr('value'); 192 | 193 | if (value) { 194 | return ctrl.$setViewValue(value); 195 | } else { 196 | return ctrl.$setViewValue(element.text()); 197 | } 198 | 199 | }); 200 | }); 201 | ctrl.$render = function() { 202 | return element.text(ctrl.$viewValue); 203 | }; 204 | ctrl.$render(); 205 | return element.on('keydown', function(e) { 206 | var del, el, esc, ret, tab; 207 | esc = e.which === 27; 208 | ret = e.which === 13; 209 | del = e.which === 8; 210 | tab = e.which === 9; 211 | el = angular.element(e.target); 212 | if (esc) { 213 | ctrl.$setViewValue(element.text()); 214 | el.blur(); 215 | return e.preventDefault(); 216 | } else if (ret && attrs.oneLine) { 217 | return e.preventDefault(); 218 | } else if (maxLength && el.text().length >= maxLength && !del && !tab) { 219 | return e.preventDefault(); 220 | } 221 | }); 222 | } 223 | } 224 | }); 225 | 226 | //---------------------------------------------------------------------------------------------------------------------- -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-markdown-editable", 3 | "version": "1.0.0", 4 | "homepage": "https://github.com/deltaepsilon/angular-markdown-editable", 5 | "authors": [ 6 | "Chris Esplin " 7 | ], 8 | "description": "ContentEditable + Markdown mashup", 9 | "main": "angular-markdown-editable.js", 10 | "keywords": [ 11 | "angular", 12 | "markdown", 13 | "contenteditable", 14 | "quiver" 15 | ], 16 | "license": "MIT", 17 | "ignore": [ 18 | "**/.*", 19 | "node_modules", 20 | "bower_components", 21 | "lib", 22 | "test", 23 | "tests", 24 | "example" 25 | ], 26 | "dependencies": { 27 | "showdown": "~0.3.1", 28 | "angular": "~1.2.14", 29 | "angular-mocks": "~1.2.14" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Angular Markdown Editable Example 4 | 5 | 6 | 7 | 14 | 27 | 28 | 29 | 30 |

Angular-Markdown-Editable

31 |

32 | Use markdown and contenteditable at the same time... seamlessly??? 33 |

34 | 35 |
36 | 37 |
38 |
{{ markdownText }}
39 | 40 |
41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /extensions/iframe.js: -------------------------------------------------------------------------------- 1 | // 2 | // iframe support 3 | // 4 | // 5 | 6 | 7 | (function(){ 8 | 9 | var iframe = function(converter) { 10 | return [ 11 | { type: 'lang', 12 | regex: '(<|<)iframe.+(<|<)\\/iframe>', 13 | replace: function(match, name, uri) { 14 | return match.replace(/</g, "<"); 15 | 16 | } 17 | } 18 | ]; 19 | }; 20 | 21 | // Client-side export 22 | if (typeof window !== 'undefined' && window.Showdown && window.Showdown.extensions) { window.Showdown.extensions.iframe = iframe; } 23 | // Server-side export 24 | if (typeof module !== 'undefined') module.exports = iframe; 25 | 26 | }()); 27 | -------------------------------------------------------------------------------- /extensions/video.js: -------------------------------------------------------------------------------- 1 | // 2 | // Video Extensions 3 | // $[GOPR0093.MP4](https://s3.amazonaws.com/assets.saltlakecycles.com/cms/GOPR0093.MP4) 4 | // 5 | // @username -> @username 6 | // #hashtag -> #hashtag 7 | // 8 | 9 | //$[GOPR0093.MP4](https://s3.amazonaws.com/assets.saltlakecycles.com/cms/GOPR0093.MP4) 10 | 11 | (function(){ 12 | 13 | var video = function(converter) { 14 | return [ 15 | 16 | // @username syntax 17 | // '\\B\\$(\\[[\\S]\\]+)\\(.+?\\)\\b' 18 | { type: 'lang', 19 | regex: '\\!\\!\\[(\\S+)\\]\\((\\S+)\\)', 20 | replace: function(match, name, uri) { 21 | // Check if we matched the leading \ and return nothing changed if so 22 | return ''; 23 | 24 | } 25 | } 26 | ]; 27 | }; 28 | 29 | // Client-side export 30 | if (typeof window !== 'undefined' && window.Showdown && window.Showdown.extensions) { window.Showdown.extensions.video = video; } 31 | // Server-side export 32 | if (typeof module !== 'undefined') module.exports = video; 33 | 34 | }()); 35 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function (config) { 2 | config.set({ 3 | basePath: '', 4 | 5 | frameworks: ['jasmine'], 6 | 7 | files: [ 8 | 'lib/angular/angular.js', 9 | 'lib/angular-mocks/angular-mocks.js', 10 | 'lib/showdown/src/showdown.js', 11 | 'angular-markdown-editable.js', 12 | 'unit-tests.js' 13 | ], 14 | 15 | plugins: [ 16 | 'karma-jasmine', 17 | 'karma-chrome-launcher' 18 | ], 19 | 20 | exclude: [], 21 | 22 | port: 9876, 23 | 24 | logLevel: config.LOG_INFO, 25 | 26 | autoWatch: false, 27 | 28 | browsers: ['Chrome'], 29 | 30 | singleRun: false 31 | }); 32 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-markdown-editable", 3 | "version": "1.0.0", 4 | "description": "ContentEditable + Markdown mashup", 5 | "main": "angular-markdown-editable.js", 6 | "scripts": { 7 | "test": "grunt test" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/deltaepsilon/angular-markdown-editable.git" 12 | }, 13 | "keywords": [ 14 | "angular", 15 | "markdown", 16 | "contenteditable", 17 | "quiver" 18 | ], 19 | "author": "Christopher Esplin (http://christopheresplin.com/)", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/deltaepsilon/angular-markdown-editable/issues" 23 | }, 24 | "homepage": "https://github.com/deltaepsilon/angular-markdown-editable", 25 | "dependencies": {}, 26 | "devDependencies": { 27 | "grunt": "~0.4.4", 28 | "karma": "~0.12.0", 29 | "grunt-karma": "~0.8.0", 30 | "load-grunt-tasks": "~0.4.0", 31 | "karma-jasmine": "~0.2.2", 32 | "karma-chrome-launcher": "~0.1.2" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /unit-tests.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | describe('Directive: markdownEditable', function () { 4 | var element, 5 | scope, 6 | timeout; 7 | 8 | beforeEach(module('angular-markdown-editable')); 9 | 10 | beforeEach(inject(function ($rootScope, $compile, $timeout) { 11 | timeout = $timeout; 12 | scope = $rootScope.$new(); 13 | scope.markdownText = "# This is an h1. \n## This is an h2. \n- This is a line item\n- This is a second line item\n\nThis is a new paragraph\n\n# Another h1"; 14 | element = angular.element('
{{ markdownText }}
'); 15 | element = $compile(element)(scope); 16 | scope.$digest(); 17 | 18 | })); 19 | 20 | it('should do an initial parse to html', function () { 21 | expect(element.html().match(/thisisanh1/).length).toBe(1); 22 | }); 23 | 24 | it('should parse to markdown on focus', function () { 25 | element.triggerHandler('focus'); 26 | scope.$digest(); 27 | 28 | expect(element.html().match(/# This is an h1/).length).toBe(1); 29 | }); 30 | 31 | it('should parse back to markdown after a focus and a blur', function () { 32 | element.triggerHandler('focus'); 33 | 34 | scope.$digest(); 35 | 36 | element.triggerHandler('blur'); 37 | timeout.flush(); 38 | 39 | expect(element.html().match(/thisisanh1/).length).toBe(1); 40 | }); 41 | 42 | it('should support model changes', function () { 43 | scope.markdownText = "# a lot simpler"; 44 | 45 | scope.$digest(); 46 | 47 | expect(element.html().match(/alotsimpler/).length).toBe(1); 48 | }); 49 | 50 | it('should propagate changes made in the markdown state to the parsed html', function () { 51 | element.triggerHandler('focus'); 52 | scope.$digest(); 53 | 54 | element.text('# a lot simpler'); 55 | scope.$digest(); 56 | 57 | expect(element.html().match(/# a lot simpler/).length).toBe(1); 58 | 59 | element.triggerHandler('blur'); 60 | timeout.flush(); 61 | 62 | expect(element.html().match(/alotsimpler/).length).toBe(1); 63 | expect(scope.markdownText).toBe('# a lot simpler'); 64 | }); 65 | 66 | it('should respect crazy whitespace', function () { 67 | scope.markdownText = "    This is a code block \n    And so is this \n\nBut this is not a code block."; 68 | scope.$digest(); 69 | 70 | // alert(element.html()); 71 | expect(element.html().match('

    This is a code block').length).toBe(1); 72 | 73 | element.triggerHandler('focus'); 74 | timeout.flush(); 75 | 76 | expect(element.html().match('

    This is a code block').length).toBe(1); 77 | 78 | element.triggerHandler('blur'); 79 | timeout.flush(); 80 | 81 | expect(element.html().match('

This is a code block').length).toBe(1);
82 | 
83 |   });
84 | 
85 | });


--------------------------------------------------------------------------------