diff --git a/.gitignore b/.gitignore index bbc031e..02a1bef 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ __MACOSX *~ Thumbs.db desktop.ini + +node_modules/* +bower_components/* diff --git a/README.md b/README.md new file mode 100644 index 0000000..b802d92 --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# Overview + +Updated, backwards incompatible version of [redactor-mentions](https://github.com/tr42/redactor-mentions) plugin that supports [Redactor 10](http://imperavi.com/redactor/) and some enhancements (like floating div by the underneath the cursor). My goal is to eventually support searching through AJAX URLs. + +Note that this project is under heavy development so no guarantees that it will work for you yet. + +# Installation + +## Manual + +Copy `redactor-mentions.min.css` and `redactor-mentions.min.js` from the `dist` folder. + +## Bower + +I am just getting started with this so I haven't published to bower yet. I will do that soon. + +# Usage + +1. Copy `redactor-mentions.min.css` and `redactor-mentions.min.js` somewhere in your assets directory. + +2. Add them to your markup after redactor stuff. + +```html + + + + +``` + +3. Add the mention plugins to your initialization: + +```javascript +$('.post').redactor({ + plugins: ['mentions'], + mentions { + url: "users.json", // user data for mentions plugin + maxUsers: 5, // maximum users to show in user select dialog + urlPrefix: "/user/", // optional url prefix for user + + // Optional. Pass in a function to format each user li. This should return + // a jQuery object. + formatUserListItem: function(user) { + return '' + user.username + ' (' + user.name + ')'; + }, + + // Optional. Pass in a function to format or modify the link that will be + // displayed in redactor. You can use this to add any additional properties to + // the link. + alterUserLink: function($mentionHref, user) { + $mentionHref.text("@" + user.name); + $mentionHref.attr("data-hovercard", "/e/" + user.username + "/_hovercard"); + } + } +}); +``` + +The users JSON data should look like: + +```javascript +[ + { + "icon": "/icons/bob.gif", + "name": "Bob", + "username": "bob" + }, + { + "icon": "/icons/alice.gif", + "name": "Alice", + "username": "alice" + } +] +``` diff --git a/README.rst b/README.rst deleted file mode 100644 index 1fcf0a7..0000000 --- a/README.rst +++ /dev/null @@ -1,34 +0,0 @@ -Getting Started -=============== - -#. Copy ``mentions.css`` and ``mentions.js`` somewhere into your assets directory. -#. Add them to your markup after redactor stuff:: - - - - - - -#. Add the mention plugins to your initialization:: - - $('.post').redactor({ - plugins: ['mentions'], - usersUrl: "users.json", // user data for mentions plugin - maxUsers: 5, // maximum users to show in user select dialog - userUrlPrefix: "/user/" // optional url prefix for user - }); - -The users JSON data should look like:: - - [ - { - "icon": "/icons/bob.gif", - "name": "Bob", - "username": "bob" - }, - { - "icon": "/icons/alice.gif", - "name": "Alice", - "username": "alice" - } - ] diff --git a/bower.json b/bower.json new file mode 100644 index 0000000..d12c072 --- /dev/null +++ b/bower.json @@ -0,0 +1,23 @@ +{ + "name": "redactor-mentions", + "version": "0.2", + "homepage": "https://github.com/thebitguru/redactor-mentions", + "authors": [ + "Jason Keene (https://github.com/jasonkeene)", + "Farhan Ahmad " + ], + "description": "Mentions plugin for redactor editor.", + "keywords": [ + "redactor", + "editor", + "mentions" + ], + "license": "MIT", + "ignore": [ + "**/.*", + "node_modules", + "bower_components", + "test", + "tests" + ] +} diff --git a/dist/redactor-mentions.css b/dist/redactor-mentions.css new file mode 100644 index 0000000..2a5a9b2 --- /dev/null +++ b/dist/redactor-mentions.css @@ -0,0 +1,28 @@ +/*! redactor-mentions - compiled at Tue Mar 31 2015 02:58:41 GMT-0500 (CDT) */ +.redactor-box .redactor-mentions-container { + position: absolute; + border: 2px solid #aaa; + background: #fff; + z-index: 5000; +} +.redactor-box .mention { + text-decoration: none !important; + cursor: default; +} +.redactor-box .user-select { + list-style-type: none; + margin: 0; + padding: 0; +} +.redactor-box .user-select li { + padding: 3px; + cursor: pointer; +} +.redactor-box .user-select li img { + margin: 0 4px 0 0; + vertical-align: bottom; +} +.redactor-box .user-select .selected { + background-color: #3577b9; + color: #ffffff; +} diff --git a/dist/redactor-mentions.js b/dist/redactor-mentions.js new file mode 100644 index 0000000..21f99eb --- /dev/null +++ b/dist/redactor-mentions.js @@ -0,0 +1,343 @@ +(function() { + var $, plugins, ref, ref1, root, users, utils; + + root = typeof exports !== "undefined" && exports !== null ? exports : this; + + $ = root.jQuery; + + utils = root.RedactorUtils = (ref = root.RedactorUtils) != null ? ref : {}; + + plugins = root.RedactorPlugins = (ref1 = root.RedactorPlugins) != null ? ref1 : {}; + + users = null; + + $.extend(utils, (function() { + var once; + once = function(func) { + func._ran = false; + func._return = null; + return function() { + if (!func._ran) { + func._ran = true; + func._return = func.apply(this, arguments); + } + return func._return; + }; + }; + return { + once: once, + any: function(arr) { + var element, j, len; + for (j = 0, len = arr.length; j < len; j++) { + element = arr[j]; + if (element) { + return true; + } + } + return false; + }, + deadLink: function(e) { + return e.preventDefault(); + }, + getCursorInfo: function() { + var range, selection; + selection = window.getSelection(); + range = selection.getRangeAt(0); + return { + selection: selection, + range: range, + offset: range.startOffset, + container: range.startContainer + }; + }, + loadUsers: once(function(url, formatUserListItem) { + return $.getJSON(url, function(data) { + var i, j, len, results, user; + users = data; + results = []; + for (i = j = 0, len = data.length; j < len; i = ++j) { + user = data[i]; + if (formatUserListItem) { + user.$element = $('
  • ' + formatUserListItem(user) + '
  • '); + } else { + user.$element = $("
  • \n " + user.username + " (" + user.name + ")\n
  • "); + } + results.push(user.$element[0].user = user); + } + return results; + }); + }), + filterTest: function(user, filter_string) { + var test_strings; + filter_string = filter_string.toLowerCase(); + test_strings = [user.username.toLowerCase(), user.name.toLowerCase()]; + return utils.any(test_strings.map(function(el) { + return el.indexOf(filter_string) !== -1; + })); + }, + createMention: function() { + var cursor_info, left, mention, new_range, right; + cursor_info = utils.getCursorInfo(); + mention = $('@\u200b'); + mention.click(utils.deadLink); + left = cursor_info.container.data.slice(0, cursor_info.offset); + right = cursor_info.container.data.slice(cursor_info.offset); + left = left.slice(0, -1); + cursor_info.container.data = left; + mention.insertAfter(cursor_info.container); + mention.after(right); + new_range = document.createRange(); + new_range.setStart(mention[0].firstChild, 1); + new_range.setEnd(mention[0].firstChild, 1); + cursor_info.selection.removeAllRanges(); + return cursor_info.selection.addRange(new_range); + }, + cursorAfterMentionStart: function() { + var cursor_info, left, ref2; + cursor_info = utils.getCursorInfo(); + if (cursor_info.container.nodeName !== "#text") { + return false; + } + left = cursor_info.container.data.slice(0, cursor_info.offset); + left = left.replace(/\u00a0/g, ' '); + left = left.replace(/\u200b/g, ''); + return (ref2 = left.slice(-2)) === '@' || ref2 === ' @'; + } + }; + })()); + + plugins.mentions = function() { + return { + init: function() { + this.mentions.select_state = null; + this.mentions.selected = null; + this.mentions.$userSelect = null; + this.mentions.validateOptions(); + utils.loadUsers(this.opts.mentions.url, this.opts.mentions.formatUserListItem); + this.mentions.setupUserSelect(); + return this.mentions.setupEditor(); + }, + validateOptions: function() { + var j, len, name, required, results; + required = ["url", "maxUsers"]; + results = []; + for (j = 0, len = required.length; j < len; j++) { + name = required[j]; + if (!this.opts.mentions[name]) { + throw "Mention plugin requires option: " + name; + } else { + results.push(void 0); + } + } + return results; + }, + setupUserSelect: function() { + this.mentions.select_state = false; + this.mentions.$containerDiv = $('
    '); + this.mentions.$containerDiv.hide(); + this.mentions.$userSelect = $('
      '); + this.mentions.$containerDiv.append(this.mentions.$userSelect); + this.mentions.$userSelect.mousemove($.proxy(this.mentions.selectMousemove, this)); + this.mentions.$userSelect.mousedown($.proxy(this.mentions.selectClick, this)); + return this.$editor.after(this.mentions.$containerDiv); + }, + setupEditor: function() { + this.$editor.on("keydown.mentions", $.proxy(this.mentions.editorKeydown, this)); + return this.$editor.on("mousedown.mentions", $.proxy(this.mentions.selectClick, this)); + }, + selectMousemove: function(e) { + var $target; + $target = $(e.target); + if ($target.hasClass('user')) { + this.mentions.selected = this.mentions.$userSelect.children().index($target); + return this.mentions.paintSelected(); + } + }, + selectClick: function(e) { + if (this.mentions.select_state) { + e.preventDefault(); + this.mentions.chooseUser(); + this.mentions.closeMention(); + this.mentions.setCursorAfterMention(); + return this.mentions.disableSelect(); + } + }, + editorKeydown: function(e) { + var tabFocus, that; + that = this; + if (this.mentions.cursorInMention()) { + switch (e.which) { + case 27: + this.mentions.closeMention(); + this.mentions.disableSelect(); + break; + case 9: + case 13: + e.preventDefault(); + tabFocus = this.opts.tabFocus; + this.opts.tabFocus = false; + if (this.mentions.select_state && this.mentions.$userSelect.children().length > 0) { + this.mentions.chooseUser(); + } + this.mentions.closeMention(); + this.mentions.setCursorAfterMention(); + this.mentions.disableSelect(); + setTimeout(function() { + return that.opts.tabFocus = tabFocus; + }, 0); + break; + case 38: + e.preventDefault(); + this.mentions.moveSelectUp(); + break; + case 40: + e.preventDefault(); + this.mentions.moveSelectDown(); + } + } else if (utils.cursorAfterMentionStart()) { + utils.createMention(); + this.mentions.enableSelect(); + } + return setTimeout($.proxy(this.mentions.updateSelect, this), 0); + }, + editorMousedown: function() { + return setTimeout($.proxy(this.mentions.updateSelect, this), 0); + }, + positionContainerDiv: function() { + var $firstNode, boxOffset, nodeOffset; + $firstNode = $(this.selection.getNodes()[0]); + boxOffset = this.$box.offset(); + nodeOffset = $firstNode.offset(); + return this.mentions.$containerDiv.css({ + left: nodeOffset.left - boxOffset.left, + top: nodeOffset.top - boxOffset.top + parseFloat($firstNode.css("line-height")) + }); + }, + updateSelect: function() { + if (this.mentions.cursorInMention()) { + this.mentions.filterUsers(); + this.mentions.positionContainerDiv(); + return this.mentions.$containerDiv.show(); + } else { + return this.mentions.$containerDiv.hide(); + } + }, + moveSelectUp: function() { + if (this.mentions.selected > 0) { + this.mentions.selected -= 1; + } + return this.mentions.paintSelected(); + }, + moveSelectDown: function() { + if (this.mentions.selected < this.mentions.$userSelect.children().length - 1) { + this.mentions.selected += 1; + } + return this.mentions.paintSelected(); + }, + enableSelect: function() { + var i, j, ref2; + this.mentions.select_state = true; + this.mentions.selected = 0; + for (i = j = 0, ref2 = this.opts.mentions.maxUsers; 0 <= ref2 ? j < ref2 : j > ref2; i = 0 <= ref2 ? ++j : --j) { + this.mentions.$userSelect.append(users[i].$element); + } + this.mentions.paintSelected(); + this.mentions.positionContainerDiv(); + return this.mentions.$containerDiv.show(); + }, + disableSelect: function() { + this.mentions.select_state = false; + this.mentions.selected = null; + this.mentions.$userSelect.children().detach(); + return this.mentions.$containerDiv.hide(); + }, + paintSelected: function() { + var $elements; + $elements = $('li', this.mentions.$userSelect); + $elements.removeClass('selected'); + return $elements.eq(this.mentions.selected).addClass('selected'); + }, + chooseUser: function() { + var $mention, prefix, user; + user = this.mentions.userFromSelected(); + prefix = this.opts.mentions.urlPrefix || '/user/'; + $mention = this.mentions.getCurrentMention(); + $mention.attr("href", prefix + user.username); + $mention.text("@" + user.username); + if (this.opts.mentions.alterUserLink) { + return this.opts.mentions.alterUserLink($mention, user); + } + }, + userFromSelected: function() { + return this.mentions.$userSelect.children('li')[this.mentions.selected].user; + }, + filterUsers: function() { + var count, filter_string, j, len, user; + this.mentions.$userSelect.children().detach(); + filter_string = this.mentions.getFilterString(); + count = 0; + for (j = 0, len = users.length; j < len; j++) { + user = users[j]; + if (count >= this.opts.mentions.maxUsers) { + break; + } + if (utils.filterTest(user, filter_string)) { + this.mentions.$userSelect.append(user.$element); + count++; + } + } + return this.mentions.paintSelected(); + }, + getFilterString: function() { + var filter_str, mention; + mention = this.mentions.getCurrentMention(); + filter_str = mention.text(); + filter_str = filter_str.slice(1); + filter_str = filter_str.replace(/\u00a0/g, ' '); + return filter_str.replace(/\u200b/g, ''); + }, + closeMention: function() { + var mention; + mention = this.mentions.getCurrentMention(); + return mention.attr("contenteditable", "false"); + }, + getCurrentMention: function() { + var current, parents; + current = $(this.selection.getCurrent()); + if (current.hasClass('mention')) { + return current; + } + parents = current.parents('.mention'); + if (parents.length > 0) { + return parents.eq(0); + } + throw "There is no current mention."; + }, + cursorInMention: function() { + var e; + try { + this.mentions.getCurrentMention().length > 0; + } catch (_error) { + e = _error; + if (e === "There is no current mention.") { + return false; + } + throw e; + } + return true; + }, + setCursorAfterMention: function() { + var mention, new_range, selection; + mention = this.mentions.getCurrentMention(); + mention.after("\u00a0"); + selection = window.getSelection(); + new_range = document.createRange(); + new_range.setStart(mention[0].nextSibling, 1); + new_range.setEnd(mention[0].nextSibling, 1); + selection.removeAllRanges(); + return selection.addRange(new_range); + } + }; + }; + +}).call(this); diff --git a/dist/redactor-mentions.min.css b/dist/redactor-mentions.min.css new file mode 100644 index 0000000..14e7d8a --- /dev/null +++ b/dist/redactor-mentions.min.css @@ -0,0 +1,2 @@ +/*! redactor-mentions - compiled at Tue Mar 31 2015 02:58:41 GMT-0500 (CDT) */.redactor-box .redactor-mentions-container{position:absolute;border:2px solid #aaa;background:#fff;z-index:5000}.redactor-box .mention{text-decoration:none!important;cursor:default}.redactor-box .user-select{list-style-type:none;margin:0;padding:0}.redactor-box .user-select li{padding:3px;cursor:pointer}.redactor-box .user-select li img{margin:0 4px 0 0;vertical-align:bottom}.redactor-box .user-select .selected{background-color:#3577b9;color:#fff} +/*# sourceMappingURL=redactor-mentions.min.css.map */ \ No newline at end of file diff --git a/dist/redactor-mentions.min.css.map b/dist/redactor-mentions.min.css.map new file mode 100644 index 0000000..361239f --- /dev/null +++ b/dist/redactor-mentions.min.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["/Users/thebitguru/Desktop/Work/Vonlay/Edison/working_copy/vonlayedison/manual_components/redactor-mentions/redactor-mentions.less"],"names":[],"mappings":"AAAA,aACI;EACI,kBAAA;EACA,sBAAA;EACA,gBAAA;EACA,aAAA;;AALR,aASI;EACI,gCAAA;EACA,eAAA;;AAXR,aAcI;EACI,qBAAA;EACA,SAAA;EACA,UAAA;;AAjBR,aAcI,aAKI;EACI,YAAA;EACA,eAAA;;AArBZ,aAcI,aAKI,GAII;EACI,iBAAA;EACA,sBAAA;;AAzBhB,aAcI,aAeI;EACI,yBAAA;EACA,cAAA","file":"redactor-mentions.min.css","sourcesContent":[".redactor-box {\n .redactor-mentions-container {\n position: absolute;\n border: 2px solid #aaa;\n background: #fff;\n z-index: 5000;\n // box-shadow: 5px 5px 15px #888;\n }\n\n .mention {\n text-decoration: none !important;\n cursor: default;\n }\n \n .user-select {\n list-style-type: none;\n margin: 0;\n padding: 0;\n\n li {\n padding: 3px;\n cursor: pointer;\n\n img {\n margin: 0 4px 0 0;\n vertical-align: bottom;\n }\n }\n\n .selected {\n background-color: #3577b9;\n color: #ffffff;\n }\n }\n}\n"],"sourceRoot":"/source/"} \ No newline at end of file diff --git a/dist/redactor-mentions.min.js b/dist/redactor-mentions.min.js new file mode 100644 index 0000000..52b4ec2 --- /dev/null +++ b/dist/redactor-mentions.min.js @@ -0,0 +1 @@ +(function(){var e,t,n,s,i,o,r;i="undefined"!=typeof exports&&null!==exports?exports:this,e=i.jQuery,r=i.RedactorUtils=null!=(n=i.RedactorUtils)?n:{},t=i.RedactorPlugins=null!=(s=i.RedactorPlugins)?s:{},o=null,e.extend(r,function(){var t;return t=function(e){return e._ran=!1,e._return=null,function(){return e._ran||(e._ran=!0,e._return=e.apply(this,arguments)),e._return}},{once:t,any:function(e){var t,n,s;for(n=0,s=e.length;s>n;n++)if(t=e[n])return!0;return!1},deadLink:function(e){return e.preventDefault()},getCursorInfo:function(){var e,t;return t=window.getSelection(),e=t.getRangeAt(0),{selection:t,range:e,offset:e.startOffset,container:e.startContainer}},loadUsers:t(function(t,n){return e.getJSON(t,function(t){var s,i,r,c,l;for(o=t,c=[],s=i=0,r=t.length;r>i;s=++i)l=t[s],l.$element=e(n?'
    1. '+n(l)+"
    2. ":'
    3. \n '+l.username+" ("+l.name+")\n
    4. "),c.push(l.$element[0].user=l);return c})}),filterTest:function(e,t){var n;return t=t.toLowerCase(),n=[e.username.toLowerCase(),e.name.toLowerCase()],r.any(n.map(function(e){return-1!==e.indexOf(t)}))},createMention:function(){var t,n,s,i,o;return t=r.getCursorInfo(),s=e('@​'),s.click(r.deadLink),n=t.container.data.slice(0,t.offset),o=t.container.data.slice(t.offset),n=n.slice(0,-1),t.container.data=n,s.insertAfter(t.container),s.after(o),i=document.createRange(),i.setStart(s[0].firstChild,1),i.setEnd(s[0].firstChild,1),t.selection.removeAllRanges(),t.selection.addRange(i)},cursorAfterMentionStart:function(){var e,t,n;return e=r.getCursorInfo(),"#text"!==e.container.nodeName?!1:(t=e.container.data.slice(0,e.offset),t=t.replace(/\u00a0/g," "),t=t.replace(/\u200b/g,""),"@"===(n=t.slice(-2))||" @"===n)}}}()),t.mentions=function(){return{init:function(){return this.mentions.select_state=null,this.mentions.selected=null,this.mentions.$userSelect=null,this.mentions.validateOptions(),r.loadUsers(this.opts.mentions.url,this.opts.mentions.formatUserListItem),this.mentions.setupUserSelect(),this.mentions.setupEditor()},validateOptions:function(){var e,t,n,s,i;for(s=["url","maxUsers"],i=[],e=0,t=s.length;t>e;e++){if(n=s[e],!this.opts.mentions[n])throw"Mention plugin requires option: "+n;i.push(void 0)}return i},setupUserSelect:function(){return this.mentions.select_state=!1,this.mentions.$containerDiv=e('
      '),this.mentions.$containerDiv.hide(),this.mentions.$userSelect=e('
        '),this.mentions.$containerDiv.append(this.mentions.$userSelect),this.mentions.$userSelect.mousemove(e.proxy(this.mentions.selectMousemove,this)),this.mentions.$userSelect.mousedown(e.proxy(this.mentions.selectClick,this)),this.$editor.after(this.mentions.$containerDiv)},setupEditor:function(){return this.$editor.on("keydown.mentions",e.proxy(this.mentions.editorKeydown,this)),this.$editor.on("mousedown.mentions",e.proxy(this.mentions.selectClick,this))},selectMousemove:function(t){var n;return n=e(t.target),n.hasClass("user")?(this.mentions.selected=this.mentions.$userSelect.children().index(n),this.mentions.paintSelected()):void 0},selectClick:function(e){return this.mentions.select_state?(e.preventDefault(),this.mentions.chooseUser(),this.mentions.closeMention(),this.mentions.setCursorAfterMention(),this.mentions.disableSelect()):void 0},editorKeydown:function(t){var n,s;if(s=this,this.mentions.cursorInMention())switch(t.which){case 27:this.mentions.closeMention(),this.mentions.disableSelect();break;case 9:case 13:t.preventDefault(),n=this.opts.tabFocus,this.opts.tabFocus=!1,this.mentions.select_state&&this.mentions.$userSelect.children().length>0&&this.mentions.chooseUser(),this.mentions.closeMention(),this.mentions.setCursorAfterMention(),this.mentions.disableSelect(),setTimeout(function(){return s.opts.tabFocus=n},0);break;case 38:t.preventDefault(),this.mentions.moveSelectUp();break;case 40:t.preventDefault(),this.mentions.moveSelectDown()}else r.cursorAfterMentionStart()&&(r.createMention(),this.mentions.enableSelect());return setTimeout(e.proxy(this.mentions.updateSelect,this),0)},editorMousedown:function(){return setTimeout(e.proxy(this.mentions.updateSelect,this),0)},positionContainerDiv:function(){var t,n,s;return t=e(this.selection.getNodes()[0]),n=this.$box.offset(),s=t.offset(),this.mentions.$containerDiv.css({left:s.left-n.left,top:s.top-n.top+parseFloat(t.css("line-height"))})},updateSelect:function(){return this.mentions.cursorInMention()?(this.mentions.filterUsers(),this.mentions.positionContainerDiv(),this.mentions.$containerDiv.show()):this.mentions.$containerDiv.hide()},moveSelectUp:function(){return this.mentions.selected>0&&(this.mentions.selected-=1),this.mentions.paintSelected()},moveSelectDown:function(){return this.mentions.selected=0?n>t:t>n;e=n>=0?++t:--t)this.mentions.$userSelect.append(o[e].$element);return this.mentions.paintSelected(),this.mentions.positionContainerDiv(),this.mentions.$containerDiv.show()},disableSelect:function(){return this.mentions.select_state=!1,this.mentions.selected=null,this.mentions.$userSelect.children().detach(),this.mentions.$containerDiv.hide()},paintSelected:function(){var t;return t=e("li",this.mentions.$userSelect),t.removeClass("selected"),t.eq(this.mentions.selected).addClass("selected")},chooseUser:function(){var e,t,n;return n=this.mentions.userFromSelected(),t=this.opts.mentions.urlPrefix||"/user/",e=this.mentions.getCurrentMention(),e.attr("href",t+n.username),e.text("@"+n.username),this.opts.mentions.alterUserLink?this.opts.mentions.alterUserLink(e,n):void 0},userFromSelected:function(){return this.mentions.$userSelect.children("li")[this.mentions.selected].user},filterUsers:function(){var e,t,n,s,i;for(this.mentions.$userSelect.children().detach(),t=this.mentions.getFilterString(),e=0,n=0,s=o.length;s>n&&(i=o[n],!(e>=this.opts.mentions.maxUsers));n++)r.filterTest(i,t)&&(this.mentions.$userSelect.append(i.$element),e++);return this.mentions.paintSelected()},getFilterString:function(){var e,t;return t=this.mentions.getCurrentMention(),e=t.text(),e=e.slice(1),e=e.replace(/\u00a0/g," "),e.replace(/\u200b/g,"")},closeMention:function(){var e;return e=this.mentions.getCurrentMention(),e.attr("contenteditable","false")},getCurrentMention:function(){var t,n;if(t=e(this.selection.getCurrent()),t.hasClass("mention"))return t;if(n=t.parents(".mention"),n.length>0)return n.eq(0);throw"There is no current mention."},cursorInMention:function(){var e;try{this.mentions.getCurrentMention().length>0}catch(t){if(e=t,"There is no current mention."===e)return!1;throw e}return!0},setCursorAfterMention:function(){var e,t,n;return e=this.mentions.getCurrentMention(),e.after(" "),n=window.getSelection(),t=document.createRange(),t.setStart(e[0].nextSibling,1),t.setEnd(e[0].nextSibling,1),n.removeAllRanges(),n.addRange(t)}}}}).call(this); \ No newline at end of file diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..68323b2 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,41 @@ +var gulp = require('gulp'); +var coffee = require('gulp-coffee'); +var less = require('gulp-less'); +var cssmin = require('gulp-cssmin'); +var uglify = require('gulp-uglify'); +var sourcemaps = require('gulp-sourcemaps'); +var header = require('gulp-header'); +var rename = require('gulp-rename'); +var pkg = require('./package.json'); + +gulp.task('default', ['compile-coffeescript', 'minify-and-copy-css']); + +gulp.task('watch', function() { + gulp.watch('redactor-mentions.coffee', ['compile-coffeescript']); + gulp.watch('redactor-mentions.less', ['compile-and-minify-css']); +}); + +gulp.task('compile-coffeescript', function() { + gulp.src('redactor-mentions.coffee') + .pipe(coffee({bare: false})) + .pipe(gulp.dest('dist')) + + .pipe(uglify()) + .pipe(rename({ suffix: '.min' })) + .pipe(gulp.dest('dist')); +}); + +gulp.task('compile-and-minify-css', function() { + gulp.src('redactor-mentions.less') + .pipe(sourcemaps.init()) + .pipe(less()) + .pipe(header( + '/*! <%= pkg.name %> - compiled at <%= new Date() %> */\n', { pkg: pkg } + )) + .pipe(gulp.dest('dist/')) + + .pipe(cssmin()) + .pipe(rename({ suffix: '.min' })) + .pipe(sourcemaps.write('./')) + .pipe(gulp.dest('dist/')); +}); diff --git a/mentions.coffee b/mentions.coffee deleted file mode 100644 index aebf53d..0000000 --- a/mentions.coffee +++ /dev/null @@ -1,355 +0,0 @@ -# grab global object -root = exports ? this - -# grab jQuery/redactor globals -$ = root.jQuery -utils = root.RedactorUtils = root.RedactorUtils ? {} -plugins = root.RedactorPlugins = root.RedactorPlugins ? {} -users = null # where we store all the user data - - -# extend utils with stuff that isn't concerned with redactor instance -$.extend utils, do -> - # needed in other parts in utils - once = (func) -> - # only run func once even if it's called multiple times - func._ran = false - func._return = null - -> - if not func._ran - func._ran = true - func._return = func.apply this, arguments - func._return - once: once - - any: (arr) -> - # if any elements of arr are truthy then return true, else false - for element in arr - return true if element - false - - deadLink: (e) -> - # event handler to kill a link (prevent event from propagating) - e.preventDefault() - - getCursorInfo: -> - # return current cursor information - selection = window.getSelection() - range = selection.getRangeAt 0 - - selection: selection - range: range - offset: range.startOffset - container: range.startContainer - - loadUsers: once (url) -> - # async call to get user data and assign it into module global - $.getJSON url, (data) -> - users = data - - for user, i in data - # create actual dom node for userSelect - user.$element = $ """ -
      1. - #{ user.username } (#{ user.name }) -
      2. """ - - # put a pointer back to user object - user.$element[0].user = user - - filterTest: (user, filter_string) -> - # test if user passes through the filter given by filter_string - filter_string = filter_string.toLowerCase() - test_strings = [ - user.username.toLowerCase() - user.name.toLowerCase() - ] - utils.any test_strings.map (el) -> - el.indexOf(filter_string) != -1 - - createMention: -> - # create a new mention and insert it at cursor position - cursor_info = utils.getCursorInfo() - mention = $ '@\u200b' - - # make sure mention links aren't clickable - mention.click utils.deadLink - - # insert mention where cursor is at - # figure out what text is left and right of the cursor - left = cursor_info.container.data.slice 0, cursor_info.offset - right = cursor_info.container.data.slice cursor_info.offset - - # slice off the @ sign - left = left.slice 0, -1 - - # insert the mention inbetween left and right - cursor_info.container.data = left - mention.insertAfter cursor_info.container - mention.after right - - # set cursor positon into mention - new_range = document.createRange() - new_range.setStart mention[0].firstChild, 1 - new_range.setEnd mention[0].firstChild, 1 - cursor_info.selection.removeAllRanges() - cursor_info.selection.addRange new_range - - cursorAfterMentionStart: -> - # test to see if the cursor is at a place where a mention can be inserted - - # get cursor element and offset - cursor_info = utils.getCursorInfo() - # if cursor isn't on a text element return false - return false if cursor_info.container.nodeName != "#text" - - # figure out what is left of the cursor - left = cursor_info.container.data.slice 0, cursor_info.offset - # replace A0 with 20 - left = left.replace /\u00a0/g, ' ' - # remove zero width spaces - left = left.replace /\u200b/g, '' - # slice off last two characters and test them - left.slice(-2) in ['@', ' @'] - - -# extend plugins with stuff that is concerned with redactor instance -$.extend plugins, do -> - mentions: - - ######### - # setup # - ######### - - init: -> - this.select_state = null # state of display of user select - this.selected = null # current user select index - this.$userSelect = null # user select element - - this.validateOptions() - utils.loadUsers(this.opts.usersUrl) - this.setupUserSelect() - this.setupEditor() - - validateOptions: -> - # make sure options are set to valid values - required = [ - "usersUrl" - "maxUsers" - ] - for name in required - if not this.opts[name] - throw "Mention plugin requires option: #{ name }" - - - setupUserSelect: -> - # init it's state to false - this.select_state = false - # create dom node - this.$userSelect = $ '
          ' - # hide it by default - this.$userSelect.hide() - # setup event handlers - this.$userSelect.mousemove $.proxy(this.selectMousemove, this) - this.$userSelect.mousedown $.proxy(this.selectMousedown, this) - # insert it into active dom tree - this.$editor.after this.$userSelect - - setupEditor: -> - # setup event handlers - this.$editor.keydown $.proxy(this.editorKeydown, this) - this.$editor.mousedown $.proxy(this.editorMousedown, this) - - ################## - # event handlers # - ################## - - # select event handlers - selectMousemove: (e) -> - $target = $ e.target - if $target.hasClass 'user' - this.selected = this.$userSelect.children().index $target - this.paintSelected() - - selectMousedown: (e) -> - if this.select_state - e.preventDefault() - this.chooseUser() - this.closeMention() - this.setCursorAfterMention() - this.disableSelect() - - # editor event handlers - editorKeydown: (e) -> - that = this - - if this.cursorInMention() - switch e.keyCode - when 27 # escape - this.closeMention() - this.disableSelect() - - when 9, 13 # tab, return - e.preventDefault() - - # work around to prevent tabs being inser - tabFocus = this.opts.tabFocus - this.opts.tabFocus = false - - if this.select_state and this.$userSelect.children().length > 0 - this.chooseUser() - - this.closeMention() - this.setCursorAfterMention() - this.disableSelect() - - # reset tabFocus when you return to the event loop - setTimeout -> - that.opts.tabFocus = tabFocus - , 0 - - when 38 # up - e.preventDefault() - this.moveSelectUp() - - when 40 # down - e.preventDefault() - this.moveSelectDown() - - else if utils.cursorAfterMentionStart() - utils.createMention() - this.enableSelect() - - # after every key press, make sure that select state is correct - setTimeout $.proxy(this.updateSelect, this), 0 - - editorMousedown: -> - # after every mousepress, make sure that select state is correct - setTimeout $.proxy(this.updateSelect, this), 0 - - ######################## - # select functionality # - ######################## - - updateSelect: -> - if this.cursorInMention() - this.filterUsers() - this.$userSelect.show() - else - this.$userSelect.hide() - - moveSelectUp: -> - if this.selected > 0 - this.selected -= 1 - this.paintSelected() - - moveSelectDown: -> - if this.selected < this.$userSelect.children().length - 1 - this.selected += 1 - this.paintSelected() - - enableSelect: -> - this.select_state = true - this.selected = 0 - - # build initial user select - for i in [0...this.opts.maxUsers] - this.$userSelect.append users[i].$element - - this.paintSelected() - this.$userSelect.show() - - disableSelect: -> - this.select_state = false - this.selected = null - this.$userSelect.children().detach() - this.$userSelect.hide() - - paintSelected: -> - $elements = $ 'li', this.$userSelect - $elements.removeClass 'selected' - $elements.eq(this.selected).addClass 'selected' - - chooseUser: -> - user = this.userFromSelected() - mention = this.getCurrentMention() - prefix = this.opts.userUrlPrefix or '/user/' - mention.attr "href", prefix + user.username - mention.text "@#{ user.username }" - - userFromSelected: -> - this.$userSelect.children('li')[this.selected].user - - filterUsers: -> - # empty out userSelect - this.$userSelect.children().detach() - - # query for filter_string once - filter_string = this.getFilterString() - - # build filtered users list - count = 0 - for user in users - # break on max filter users - break if count >= this.opts.maxUsers - - if utils.filterTest user, filter_string - this.$userSelect.append user.$element - count++ - - this.paintSelected() - - getFilterString: -> - mention = this.getCurrentMention() - filter_str = mention.text() - # remove @ from the begining - filter_str = filter_str.slice 1 - # replace A0 with 20 - filter_str = filter_str.replace /\u00a0/g, ' ' - # remove zero width spaces - filter_str.replace /\u200b/g, '' - - ######################### - # mention functionality # - ######################### - - closeMention: -> - mention = this.getCurrentMention() - mention.attr "contenteditable", "false" - - getCurrentMention: -> - # return the current mention based on cursor position, if there - # isn't one then return false - - # first check the current element, if it is a mention return it - current = $ this.getCurrent() - return current if current.hasClass 'mention' - - # else select from parents - parents = current.parents '.mention' - return parents.eq 0 if parents.length > 0 - - # throw if there isn't a current mention - throw "There is no current mention." - - cursorInMention: -> - try - this.getCurrentMention().length > 0 - catch e - return false if e == "There is no current mention." - throw e - true - - setCursorAfterMention: -> - mention = this.getCurrentMention() - - # insert space after mention - mention.after "\u00a0" - - # set cursor - selection = window.getSelection() - new_range = document.createRange() - new_range.setStart mention[0].nextSibling, 1 - new_range.setEnd mention[0].nextSibling, 1 - selection.removeAllRanges() - selection.addRange new_range diff --git a/mentions.css b/mentions.css deleted file mode 100644 index 48f4fab..0000000 --- a/mentions.css +++ /dev/null @@ -1,25 +0,0 @@ -.redactor_box .mention { - text-decoration: none !important; - color: #e03c00 !important; - cursor: default; -} -.redactor_box .user_select { - list-style-type: none; - margin: 0; - padding: 3px 0 0 0; - font-family: sans-serif; - font-size: 14px; - min-height: 20px; -} -.redactor_box .user_select li { - padding: 3px; - margin: 2px 0; -} -.redactor_box .user_select .selected { - background-color: #e03c00; - color: #ffffff; -} -.redactor_box .user_select li img { - margin: 0 4px 0 0; - vertical-align: bottom; -} diff --git a/mentions.js b/mentions.js deleted file mode 100644 index 00e0f30..0000000 --- a/mentions.js +++ /dev/null @@ -1,325 +0,0 @@ -// Generated by CoffeeScript 1.7.1 -(function() { - var $, plugins, root, users, utils, _ref, _ref1; - - root = typeof exports !== "undefined" && exports !== null ? exports : this; - - $ = root.jQuery; - - utils = root.RedactorUtils = (_ref = root.RedactorUtils) != null ? _ref : {}; - - plugins = root.RedactorPlugins = (_ref1 = root.RedactorPlugins) != null ? _ref1 : {}; - - users = null; - - $.extend(utils, (function() { - var once; - once = function(func) { - func._ran = false; - func._return = null; - return function() { - if (!func._ran) { - func._ran = true; - func._return = func.apply(this, arguments); - } - return func._return; - }; - }; - return { - once: once, - any: function(arr) { - var element, _i, _len; - for (_i = 0, _len = arr.length; _i < _len; _i++) { - element = arr[_i]; - if (element) { - return true; - } - } - return false; - }, - deadLink: function(e) { - return e.preventDefault(); - }, - getCursorInfo: function() { - var range, selection; - selection = window.getSelection(); - range = selection.getRangeAt(0); - return { - selection: selection, - range: range, - offset: range.startOffset, - container: range.startContainer - }; - }, - loadUsers: once(function(url) { - return $.getJSON(url, function(data) { - var i, user, _i, _len, _results; - users = data; - _results = []; - for (i = _i = 0, _len = data.length; _i < _len; i = ++_i) { - user = data[i]; - user.$element = $("
        1. \n " + user.username + " (" + user.name + ")\n
        2. "); - _results.push(user.$element[0].user = user); - } - return _results; - }); - }), - filterTest: function(user, filter_string) { - var test_strings; - filter_string = filter_string.toLowerCase(); - test_strings = [user.username.toLowerCase(), user.name.toLowerCase()]; - return utils.any(test_strings.map(function(el) { - return el.indexOf(filter_string) !== -1; - })); - }, - createMention: function() { - var cursor_info, left, mention, new_range, right; - cursor_info = utils.getCursorInfo(); - mention = $('@\u200b'); - mention.click(utils.deadLink); - left = cursor_info.container.data.slice(0, cursor_info.offset); - right = cursor_info.container.data.slice(cursor_info.offset); - left = left.slice(0, -1); - cursor_info.container.data = left; - mention.insertAfter(cursor_info.container); - mention.after(right); - new_range = document.createRange(); - new_range.setStart(mention[0].firstChild, 1); - new_range.setEnd(mention[0].firstChild, 1); - cursor_info.selection.removeAllRanges(); - return cursor_info.selection.addRange(new_range); - }, - cursorAfterMentionStart: function() { - var cursor_info, left, _ref2; - cursor_info = utils.getCursorInfo(); - if (cursor_info.container.nodeName !== "#text") { - return false; - } - left = cursor_info.container.data.slice(0, cursor_info.offset); - left = left.replace(/\u00a0/g, ' '); - left = left.replace(/\u200b/g, ''); - return (_ref2 = left.slice(-2)) === '@' || _ref2 === ' @'; - } - }; - })()); - - $.extend(plugins, (function() { - return { - mentions: { - init: function() { - this.select_state = null; - this.selected = null; - this.$userSelect = null; - this.validateOptions(); - utils.loadUsers(this.opts.usersUrl); - this.setupUserSelect(); - return this.setupEditor(); - }, - validateOptions: function() { - var name, required, _i, _len, _results; - required = ["usersUrl", "maxUsers"]; - _results = []; - for (_i = 0, _len = required.length; _i < _len; _i++) { - name = required[_i]; - if (!this.opts[name]) { - throw "Mention plugin requires option: " + name; - } else { - _results.push(void 0); - } - } - return _results; - }, - setupUserSelect: function() { - this.select_state = false; - this.$userSelect = $('
            '); - this.$userSelect.hide(); - this.$userSelect.mousemove($.proxy(this.selectMousemove, this)); - this.$userSelect.mousedown($.proxy(this.selectMousedown, this)); - return this.$editor.after(this.$userSelect); - }, - setupEditor: function() { - this.$editor.keydown($.proxy(this.editorKeydown, this)); - return this.$editor.mousedown($.proxy(this.editorMousedown, this)); - }, - selectMousemove: function(e) { - var $target; - $target = $(e.target); - if ($target.hasClass('user')) { - this.selected = this.$userSelect.children().index($target); - return this.paintSelected(); - } - }, - selectMousedown: function(e) { - if (this.select_state) { - e.preventDefault(); - this.chooseUser(); - this.closeMention(); - this.setCursorAfterMention(); - return this.disableSelect(); - } - }, - editorKeydown: function(e) { - var tabFocus, that; - that = this; - if (this.cursorInMention()) { - switch (e.keyCode) { - case 27: - this.closeMention(); - this.disableSelect(); - break; - case 9: - case 13: - e.preventDefault(); - tabFocus = this.opts.tabFocus; - this.opts.tabFocus = false; - if (this.select_state && this.$userSelect.children().length > 0) { - this.chooseUser(); - } - this.closeMention(); - this.setCursorAfterMention(); - this.disableSelect(); - setTimeout(function() { - return that.opts.tabFocus = tabFocus; - }, 0); - break; - case 38: - e.preventDefault(); - this.moveSelectUp(); - break; - case 40: - e.preventDefault(); - this.moveSelectDown(); - } - } else if (utils.cursorAfterMentionStart()) { - utils.createMention(); - this.enableSelect(); - } - return setTimeout($.proxy(this.updateSelect, this), 0); - }, - editorMousedown: function() { - return setTimeout($.proxy(this.updateSelect, this), 0); - }, - updateSelect: function() { - if (this.cursorInMention()) { - this.filterUsers(); - return this.$userSelect.show(); - } else { - return this.$userSelect.hide(); - } - }, - moveSelectUp: function() { - if (this.selected > 0) { - this.selected -= 1; - } - return this.paintSelected(); - }, - moveSelectDown: function() { - if (this.selected < this.$userSelect.children().length - 1) { - this.selected += 1; - } - return this.paintSelected(); - }, - enableSelect: function() { - var i, _i, _ref2; - this.select_state = true; - this.selected = 0; - for (i = _i = 0, _ref2 = this.opts.maxUsers; 0 <= _ref2 ? _i < _ref2 : _i > _ref2; i = 0 <= _ref2 ? ++_i : --_i) { - this.$userSelect.append(users[i].$element); - } - this.paintSelected(); - return this.$userSelect.show(); - }, - disableSelect: function() { - this.select_state = false; - this.selected = null; - this.$userSelect.children().detach(); - return this.$userSelect.hide(); - }, - paintSelected: function() { - var $elements; - $elements = $('li', this.$userSelect); - $elements.removeClass('selected'); - return $elements.eq(this.selected).addClass('selected'); - }, - chooseUser: function() { - var mention, prefix, user; - user = this.userFromSelected(); - mention = this.getCurrentMention(); - prefix = this.opts.userUrlPrefix || '/user/'; - mention.attr("href", prefix + user.username); - return mention.text("@" + user.username); - }, - userFromSelected: function() { - return this.$userSelect.children('li')[this.selected].user; - }, - filterUsers: function() { - var count, filter_string, user, _i, _len; - this.$userSelect.children().detach(); - filter_string = this.getFilterString(); - count = 0; - for (_i = 0, _len = users.length; _i < _len; _i++) { - user = users[_i]; - if (count >= this.opts.maxUsers) { - break; - } - if (utils.filterTest(user, filter_string)) { - this.$userSelect.append(user.$element); - count++; - } - } - return this.paintSelected(); - }, - getFilterString: function() { - var filter_str, mention; - mention = this.getCurrentMention(); - filter_str = mention.text(); - filter_str = filter_str.slice(1); - filter_str = filter_str.replace(/\u00a0/g, ' '); - return filter_str.replace(/\u200b/g, ''); - }, - closeMention: function() { - var mention; - mention = this.getCurrentMention(); - return mention.attr("contenteditable", "false"); - }, - getCurrentMention: function() { - var current, parents; - current = $(this.getCurrent()); - if (current.hasClass('mention')) { - return current; - } - parents = current.parents('.mention'); - if (parents.length > 0) { - return parents.eq(0); - } - throw "There is no current mention."; - }, - cursorInMention: function() { - var e; - try { - this.getCurrentMention().length > 0; - } catch (_error) { - e = _error; - if (e === "There is no current mention.") { - return false; - } - throw e; - } - return true; - }, - setCursorAfterMention: function() { - var mention, new_range, selection; - mention = this.getCurrentMention(); - mention.after("\u00a0"); - selection = window.getSelection(); - new_range = document.createRange(); - new_range.setStart(mention[0].nextSibling, 1); - new_range.setEnd(mention[0].nextSibling, 1); - selection.removeAllRanges(); - return selection.addRange(new_range); - } - } - }; - })()); - -}).call(this); diff --git a/notes.txt b/notes.txt new file mode 100644 index 0000000..37d74ea --- /dev/null +++ b/notes.txt @@ -0,0 +1,11 @@ +Commit Notes + + + +-------------------------------------------------------------------------------- +TODO + +- Add support for server side search. + "dependencies": { + "jquery-throttle-debounce": "*" + } diff --git a/package.json b/package.json new file mode 100644 index 0000000..3fe092d --- /dev/null +++ b/package.json @@ -0,0 +1,37 @@ +{ + "name": "redactor-mentions", + "version": "0.2.0", + "description": "Mentions plugin for redactor editor.", + "main": "mentions.js", + "directories": { + "doc": "docs" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "https://github.com/thebitguru/redactor-mentions.git" + }, + "keywords": [ + "redactor", + "mentions", + "plugin" + ], + "author": "Farhan Ahmad ", + "license": "MIT", + "bugs": { + "url": "https://github.com/thebitguru/redactor-mentions/issues" + }, + "homepage": "https://github.com/thebitguru/redactor-mentions", + "dependencies": { + "gulp": "^3.8.11", + "gulp-coffee": "^2.3.1", + "gulp-cssmin": "^0.1.6", + "gulp-header": "^1.2.2", + "gulp-less": "^3.0.2", + "gulp-rename": "^1.2.0", + "gulp-sourcemaps": "^1.5.1", + "gulp-uglify": "^1.1.0" + } +} diff --git a/redactor-mentions.coffee b/redactor-mentions.coffee new file mode 100644 index 0000000..7d7fb85 --- /dev/null +++ b/redactor-mentions.coffee @@ -0,0 +1,373 @@ +# grab global object +root = exports ? this + +# grab jQuery/redactor globals +$ = root.jQuery +utils = root.RedactorUtils = root.RedactorUtils ? {} +plugins = root.RedactorPlugins = root.RedactorPlugins ? {} +users = null # where we store all the user data + + +# extend utils with stuff that isn't concerned with redactor instance +$.extend utils, do -> + # needed in other parts in utils + once = (func) -> + # only run func once even if it's called multiple times + func._ran = false + func._return = null + -> + if not func._ran + func._ran = true + func._return = func.apply this, arguments + func._return + once: once + + any: (arr) -> + # if any elements of arr are truthy then return true, else false + for element in arr + return true if element + false + + deadLink: (e) -> + # event handler to kill a link (prevent event from propagating) + e.preventDefault() + + getCursorInfo: -> + # return current cursor information + selection = window.getSelection() + range = selection.getRangeAt 0 + + selection: selection + range: range + offset: range.startOffset + container: range.startContainer + + loadUsers: once (url, formatUserListItem) -> + # async call to get user data and assign it into module global + $.getJSON url, (data) -> + users = data + + for user, i in data + # create actual dom node for userSelect + if formatUserListItem + user.$element = $ '
          1. ' + formatUserListItem(user) + '
          2. ' + else + user.$element = $ """ +
          3. + #{ user.username } (#{ user.name }) +
          4. """ + + # put a pointer back to user object + user.$element[0].user = user + + + filterTest: (user, filter_string) -> + # test if user passes through the filter given by filter_string + filter_string = filter_string.toLowerCase() + test_strings = [ + user.username.toLowerCase() + user.name.toLowerCase() + ] + utils.any test_strings.map (el) -> + el.indexOf(filter_string) != -1 + + createMention: -> + # create a new mention and insert it at cursor position + cursor_info = utils.getCursorInfo() + mention = $ '@\u200b' + + # make sure mention links aren't clickable + mention.click utils.deadLink + + # insert mention where cursor is at + # figure out what text is left and right of the cursor + left = cursor_info.container.data.slice 0, cursor_info.offset + right = cursor_info.container.data.slice cursor_info.offset + + # slice off the @ sign + left = left.slice 0, -1 + + # insert the mention inbetween left and right + cursor_info.container.data = left + mention.insertAfter cursor_info.container + mention.after right + + # set cursor positon into mention + new_range = document.createRange() + new_range.setStart mention[0].firstChild, 1 + new_range.setEnd mention[0].firstChild, 1 + cursor_info.selection.removeAllRanges() + cursor_info.selection.addRange new_range + + cursorAfterMentionStart: -> + # test to see if the cursor is at a place where a mention can be inserted + + # get cursor element and offset + cursor_info = utils.getCursorInfo() + # if cursor isn't on a text element return false + return false if cursor_info.container.nodeName != "#text" + + # figure out what is left of the cursor + left = cursor_info.container.data.slice 0, cursor_info.offset + # replace A0 with 20 + left = left.replace /\u00a0/g, ' ' + # remove zero width spaces + left = left.replace /\u200b/g, '' + # slice off last two characters and test them + left.slice(-2) in ['@', ' @'] + + +# extend plugins with stuff that is concerned with redactor instance +plugins.mentions = -> + ######### + # setup # + ######### + + init: -> + this.mentions.select_state = null # state of display of user select + this.mentions.selected = null # current user select index + this.mentions.$userSelect = null # user select element + + this.mentions.validateOptions() + utils.loadUsers(this.opts.mentions.url, this.opts.mentions.formatUserListItem) + this.mentions.setupUserSelect() + this.mentions.setupEditor() + + validateOptions: -> + # make sure options are set to valid values + required = [ + "url" + "maxUsers" + ] + for name in required + if not this.opts.mentions[name] + throw "Mention plugin requires option: #{ name }" + + + setupUserSelect: -> + # init it's state to false + this.mentions.select_state = false + # create dom nodes + this.mentions.$containerDiv = $ '
            ' + # hide it by default + this.mentions.$containerDiv.hide() + + this.mentions.$userSelect = $ '
              ' + this.mentions.$containerDiv.append this.mentions.$userSelect + # setup event handlers + this.mentions.$userSelect.mousemove $.proxy(this.mentions.selectMousemove, this) + this.mentions.$userSelect.mousedown $.proxy(this.mentions.selectClick, this) + # insert it into active dom tree + this.$editor.after this.mentions.$containerDiv + + setupEditor: -> + # setup event handlers + this.$editor.on "keydown.mentions", $.proxy(this.mentions.editorKeydown, this) + this.$editor.on "mousedown.mentions", $.proxy(this.mentions.selectClick, this) + + ################## + # event handlers # + ################## + + # select event handlers + selectMousemove: (e) -> + $target = $ e.target + if $target.hasClass 'user' + this.mentions.selected = this.mentions.$userSelect.children().index $target + this.mentions.paintSelected() + + selectClick: (e) -> + if this.mentions.select_state + e.preventDefault() + this.mentions.chooseUser() + this.mentions.closeMention() + this.mentions.setCursorAfterMention() + this.mentions.disableSelect() + + # editor event handlers + editorKeydown: (e) -> + that = this + + if this.mentions.cursorInMention() + switch e.which + when 27 # escape + this.mentions.closeMention() + this.mentions.disableSelect() + + when 9, 13 # tab, return + e.preventDefault() + + # work around to prevent tabs being inserted + tabFocus = this.opts.tabFocus + this.opts.tabFocus = false + + if this.mentions.select_state and this.mentions.$userSelect.children().length > 0 + this.mentions.chooseUser() + + this.mentions.closeMention() + this.mentions.setCursorAfterMention() + this.mentions.disableSelect() + + # reset tabFocus when you return to the event loop + setTimeout -> + that.opts.tabFocus = tabFocus + , 0 + + when 38 # up + e.preventDefault() + this.mentions.moveSelectUp() + + when 40 # down + e.preventDefault() + this.mentions.moveSelectDown() + + else if utils.cursorAfterMentionStart() + utils.createMention() + this.mentions.enableSelect() + + # after every key press, make sure that select state is correct + setTimeout $.proxy(this.mentions.updateSelect, this), 0 + + editorMousedown: -> + # after every mousepress, make sure that select state is correct + setTimeout $.proxy(this.mentions.updateSelect, this), 0 + + ######################## + # select functionality # + ######################## + + positionContainerDiv: -> + $firstNode = $ this.selection.getNodes()[0] + boxOffset = this.$box.offset() + nodeOffset = $firstNode.offset() + this.mentions.$containerDiv.css( + left: nodeOffset.left - boxOffset.left, + top: nodeOffset.top - boxOffset.top + parseFloat($firstNode.css("line-height")) + ) + + updateSelect: -> + if this.mentions.cursorInMention() + this.mentions.filterUsers() + this.mentions.positionContainerDiv() + this.mentions.$containerDiv.show() + else + this.mentions.$containerDiv.hide() + + moveSelectUp: -> + if this.mentions.selected > 0 + this.mentions.selected -= 1 + this.mentions.paintSelected() + + moveSelectDown: -> + if this.mentions.selected < this.mentions.$userSelect.children().length - 1 + this.mentions.selected += 1 + this.mentions.paintSelected() + + enableSelect: -> + this.mentions.select_state = true + this.mentions.selected = 0 + + # build initial user select + for i in [0...this.opts.mentions.maxUsers] + this.mentions.$userSelect.append users[i].$element + + this.mentions.paintSelected() + this.mentions.positionContainerDiv() + this.mentions.$containerDiv.show() + + disableSelect: -> + this.mentions.select_state = false + this.mentions.selected = null + this.mentions.$userSelect.children().detach() + this.mentions.$containerDiv.hide() + + paintSelected: -> + $elements = $ 'li', this.mentions.$userSelect + $elements.removeClass 'selected' + $elements.eq(this.mentions.selected).addClass 'selected' + + chooseUser: -> + user = this.mentions.userFromSelected() + prefix = this.opts.mentions.urlPrefix or '/user/' + $mention = this.mentions.getCurrentMention() + $mention.attr "href", prefix + user.username + $mention.text "@#{ user.username }" + if this.opts.mentions.alterUserLink + this.opts.mentions.alterUserLink $mention, user + + userFromSelected: -> + this.mentions.$userSelect.children('li')[this.mentions.selected].user + + filterUsers: -> + # empty out userSelect + this.mentions.$userSelect.children().detach() + + # query for filter_string once + filter_string = this.mentions.getFilterString() + + # build filtered users list + count = 0 + for user in users + # break on max filter users + break if count >= this.opts.mentions.maxUsers + + if utils.filterTest user, filter_string + this.mentions.$userSelect.append user.$element + count++ + + this.mentions.paintSelected() + + getFilterString: -> + mention = this.mentions.getCurrentMention() + filter_str = mention.text() + # remove @ from the begining + filter_str = filter_str.slice 1 + # replace A0 with 20 + filter_str = filter_str.replace /\u00a0/g, ' ' + # remove zero width spaces + filter_str.replace /\u200b/g, '' + + ######################### + # mention functionality # + ######################### + + closeMention: -> + mention = this.mentions.getCurrentMention() + mention.attr "contenteditable", "false" + + getCurrentMention: -> + # return the current mention based on cursor position, if there + # isn't one then return false + + # first check the current element, if it is a mention return it + current = $ this.selection.getCurrent() + return current if current.hasClass 'mention' + + # else select from parents + parents = current.parents '.mention' + return parents.eq 0 if parents.length > 0 + + # throw if there isn't a current mention + throw "There is no current mention." + + cursorInMention: -> + try + this.mentions.getCurrentMention().length > 0 + catch e + return false if e == "There is no current mention." + throw e + true + + setCursorAfterMention: -> + mention = this.mentions.getCurrentMention() + + # insert space after mention + mention.after "\u00a0" + + # set cursor + selection = window.getSelection() + new_range = document.createRange() + new_range.setStart mention[0].nextSibling, 1 + new_range.setEnd mention[0].nextSibling, 1 + selection.removeAllRanges() + selection.addRange new_range diff --git a/redactor-mentions.less b/redactor-mentions.less new file mode 100644 index 0000000..40d22c7 --- /dev/null +++ b/redactor-mentions.less @@ -0,0 +1,35 @@ +.redactor-box { + .redactor-mentions-container { + position: absolute; + border: 2px solid #aaa; + background: #fff; + z-index: 5000; + // box-shadow: 5px 5px 15px #888; + } + + .mention { + text-decoration: none !important; + cursor: default; + } + + .user-select { + list-style-type: none; + margin: 0; + padding: 0; + + li { + padding: 3px; + cursor: pointer; + + img { + margin: 0 4px 0 0; + vertical-align: bottom; + } + } + + .selected { + background-color: #3577b9; + color: #ffffff; + } + } +}