├── spec ├── helpers │ └── noConflict.js ├── javascripts │ ├── fixtures │ │ ├── inputors.html │ │ └── json │ │ │ └── data.json │ ├── iframe.spec.coffee │ ├── content_editable.spec.coffee │ ├── view.spec.coffee │ ├── apis.spec.coffee │ ├── custom_callbacks.spec.coffee │ ├── events.spec.coffee │ ├── settings.spec.coffee │ └── default_callbacks.spec.coffee └── spec_helper.coffee ├── .gitignore ├── .codoopts ├── examples ├── cross_document │ ├── viewFrame.html │ ├── dataFrame.html │ └── index.html ├── style.css ├── ueditor.html ├── medium-editor.html ├── tinyMCE.html └── hashtags.html ├── .travis.yml ├── umd.template.js ├── bower.json ├── component.json ├── dist ├── css │ ├── jquery.atwho.min.css │ └── jquery.atwho.css └── js │ ├── jquery.atwho.min.js │ └── jquery.atwho.js ├── LICENSE-MIT ├── package.json ├── src ├── jquery.atwho.css ├── api.coffee ├── model.coffee ├── textareaController.coffee ├── view.coffee ├── controller.coffee ├── default.coffee ├── app.coffee └── editableController.coffee ├── CONTRIBUTING.md ├── specRunner.html ├── README.md ├── gulpfile.js ├── index.html └── CHANGELOG.md /spec/helpers/noConflict.js: -------------------------------------------------------------------------------- 1 | jQuery.noConflict(); -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | .DS_Store 3 | doc/ 4 | node_modules/ 5 | _* 6 | bower_components/ 7 | components 8 | .grunt 9 | *.log 10 | build/ 11 | -------------------------------------------------------------------------------- /.codoopts: -------------------------------------------------------------------------------- 1 | --name "Codo" 2 | --readme README.md 3 | --title "At.js Documentation" 4 | --private 5 | --quiet 6 | --output-dir ./doc 7 | ./src 8 | - 9 | LICENSE 10 | CHANGELOG.md 11 | -------------------------------------------------------------------------------- /examples/cross_document/viewFrame.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | View Iframe 5 | 6 | 7 | 8 |

hello!

9 | 10 | 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4" 4 | notifications: 5 | email: 6 | on_success: never # default: change 7 | on_failure: change # default: always 8 | branches: 9 | only: 10 | - master 11 | - stable 12 | - beta 13 | before_script: 14 | - npm install -g gulp 15 | - npm install -g bower 16 | - bower install 17 | -------------------------------------------------------------------------------- /umd.template.js: -------------------------------------------------------------------------------- 1 | (function (root, factory) { 2 | if (typeof define === 'function' && define.amd) { 3 | // AMD. Register as an anonymous module unless amdModuleId is set 4 | define(["jquery"], function (a0) { 5 | return (factory(a0)); 6 | }); 7 | } else if (typeof exports === 'object') { 8 | // Node. Does not work with strict CommonJS, but 9 | // only CommonJS-like environments that support module.exports, 10 | // like Node. 11 | module.exports = factory(require("jquery")); 12 | } else { 13 | factory(jQuery); 14 | } 15 | }(this, function ($) { 16 | <%= contents %> 17 | })); 18 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "At.js", 3 | "version": "1.5.7", 4 | "main": [ 5 | "dist/js/jquery.atwho.js", 6 | "dist/css/jquery.atwho.css" 7 | ], 8 | "ignore": [ 9 | "**/.*", 10 | "node_modules", 11 | "components", 12 | "libs", 13 | "spec" 14 | ], 15 | "dependencies": { 16 | "jquery": ">=1.7.0", 17 | "Caret.js": "~0.2.2" 18 | }, 19 | "devDependencies": { 20 | "jasmine-jquery": "~2.0.2" 21 | }, 22 | "keywords": [ 23 | "mention", 24 | "mentions", 25 | "autocomplete", 26 | "autocompletion", 27 | "autosuggest", 28 | "autosuggestion", 29 | "atjs", 30 | "at.js" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "At.js", 3 | "repo": "ichord/At.js", 4 | "description": "Add Github like mentions autocomplete to your application.", 5 | "version": "1.5.7", 6 | "demo": "http://ichord.github.com/At.js", 7 | "dependencies": { 8 | "ichord/Caret.js": "~0.2.2", 9 | "component/jquery": ">= 1.7.0" 10 | }, 11 | "main": [ 12 | "dist/js/jquery.atwho.js" 13 | ], 14 | "scripts": [ 15 | "dist/js/jquery.atwho.js" 16 | ], 17 | "styles": [ 18 | "dist/css/jquery.atwho.css" 19 | ], 20 | "license": "MIT", 21 | "keywords": [ 22 | "mentions", 23 | "ui", 24 | "mentions", 25 | "autocomplete", 26 | "autocompletion", 27 | "autosuggest", 28 | "autosuggestion", 29 | "atjs", 30 | "at.js" 31 | ] 32 | } -------------------------------------------------------------------------------- /spec/javascripts/fixtures/inputors.html: -------------------------------------------------------------------------------- 1 | 4 | 5 | 9 | 10 | 13 | 14 | 17 | 18 |
19 | Stay Foolish, Stay Hungry. @J sss 20 | shfsdfssfasf sjfsl 21 |
22 | 23 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /dist/css/jquery.atwho.min.css: -------------------------------------------------------------------------------- 1 | .atwho-view{position:absolute;top:0;left:0;display:none;margin-top:18px;background:#fff;color:#000;border:1px solid #DDD;border-radius:3px;box-shadow:0 0 5px rgba(0,0,0,.1);min-width:120px;z-index:11110!important}.atwho-view .atwho-header{padding:5px;margin:5px;cursor:pointer;border-bottom:solid 1px #eaeff1;color:#6f8092;font-size:11px;font-weight:700}.atwho-view .atwho-header .small{color:#6f8092;float:right;padding-top:2px;margin-right:-5px;font-size:12px;font-weight:400}.atwho-view .atwho-header:hover{cursor:default}.atwho-view .cur{background:#36F;color:#fff}.atwho-view .cur small{color:#fff}.atwho-view strong{color:#36F}.atwho-view .cur strong{color:#fff;font:700}.atwho-view ul{list-style:none;padding:0;margin:auto;max-height:200px;overflow-y:auto}.atwho-view ul li{display:block;padding:5px 10px;border-bottom:1px solid #DDD;cursor:pointer}.atwho-view small{font-size:smaller;color:#777;font-weight:400} -------------------------------------------------------------------------------- /spec/javascripts/fixtures/json/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "names" : [ 3 | "Jacob","Isabella","Ethan","Emma","Michael","Olivia", 4 | "Alexander","Sophia","William","Ava","Joshua","Emily", 5 | "Daniel","Madison","Jayden","lepture","Abigail","Noah", 6 | "Chloe","aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","你好", 7 | "你你你", "高富帅", "Jérémy" 8 | ], 9 | "emojis": [ 10 | "six_pointed_star", "ski", "skull", "sleepy", "slot_machine", "smile", 11 | "smiley", "smirk", "smoking", "snake", "snowman", "sob", "soccer", 12 | "space_invader", "spades", "spaghetti", "sparkler", "sparkles", 13 | "speaker", "speedboat", "squirrel", "star", "star2", "stars", "station", 14 | "statue_of_liberty", "stew", "strawberry", "sunflower", "sunny", 15 | "sunrise", "sunrise_over_mountains", "surfer", "sushi", "suspect", 16 | "sweat", "sweat_drops", "swimmer", "syringe", "tada", "tangerine", 17 | "taurus", "taxi", "tea", "telephone", "tennis", "tent", "thumbsdown", "+1","-1" 18 | ], 19 | "numerics": [ 20 | 6, 7, 13, 5, 4, 9, 1, 15, 14, 3, 8, 12, 2, 11, 10 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /examples/style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | background:#F9F9F9; 3 | padding: 0; 4 | margin: 0; 5 | font: 14px/1.6 "Lucida Grande", "Helvetica", sans-serif; 6 | color: #333; 7 | } 8 | h1,h2,h3,h4 { 9 | font-family: 'PT Sans', sans-serif; 10 | line-height: 40px; 11 | color: inherit; 12 | font-weight: bold; 13 | margin: 10px 0; 14 | text-rendering: optimizelegibility; 15 | } 16 | h2,h3 { 17 | color: gray; 18 | } 19 | strong { 20 | color: #424242; 21 | } 22 | 23 | a { 24 | color: #4183C4; 25 | text-decoration: none; 26 | } 27 | a:hover { 28 | text-decoration: underline; 29 | } 30 | .wrapper { 31 | width: 750px; 32 | padding: 20px; 33 | margin: 0 auto; 34 | } 35 | header { 36 | margin-top:30px; 37 | } 38 | 39 | .inputor { 40 | height: 160px; 41 | width: 90%; 42 | border: 1px solid #dadada; 43 | border-radius: 4px; 44 | padding: 5px 8px; 45 | outline: 0 none; 46 | margin: 10px 0; 47 | background: white; 48 | font-size: inherit; 49 | overflow-y: scroll; 50 | } 51 | .inputor:focus { 52 | border: 1px solid rgb(6, 150, 247); 53 | } 54 | 55 | footer { 56 | margin: 30px 0; 57 | } 58 | -------------------------------------------------------------------------------- /spec/javascripts/iframe.spec.coffee: -------------------------------------------------------------------------------- 1 | describe "iframe editor", -> 2 | $inputor = null 3 | app = null 4 | $ = jQuery 5 | beforeEach -> 6 | loadFixtures "inputors.html" 7 | ifr = $('#iframeInput')[0] 8 | doc = ifr.contentDocument || iframe.contentWindow.document 9 | if (ifrBody = doc.body) is null # IE 10 | doc.write "" 11 | ifrBody = doc.body 12 | ifrBody.contentEditable = true 13 | ifrBody.id = 'ifrBody' 14 | ifrBody.innerHTML = 'Stay Foolish, Stay Hungry. @Jobs' 15 | $inputor = $(ifrBody) 16 | $inputor.atwho('setIframe', ifr) 17 | $inputor.atwho(at: "@", data: ['Jobs']) 18 | app = getAppOf $inputor 19 | 20 | afterEach -> 21 | $inputor.atwho 'destroy' 22 | 23 | it "can insert content", -> 24 | triggerAtwhoAt $inputor 25 | expect($inputor.text()).toContain('@Jobs') 26 | 27 | it "insert by click", -> 28 | simulateTypingIn $inputor 29 | app.controller().view.$el.find('ul').children().first().trigger('click') 30 | expect($inputor.text()).toContain('@Jobs') 31 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 chord.luo@gmail.com 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /examples/cross_document/dataFrame.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Data Iframe 5 | 6 | 7 | 8 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /spec/spec_helper.coffee: -------------------------------------------------------------------------------- 1 | $ = jQuery 2 | 3 | @KEY_CODE = 4 | DOWN: 40 5 | UP: 38 6 | ESC: 27 7 | TAB: 9 8 | ENTER: 13 9 | CTRL: 17 10 | P: 80 11 | N: 78 12 | 13 | @fixtures or= loadJSONFixtures("data.json")["data.json"] 14 | 15 | @triggerAtwhoAt = ($inputor) -> 16 | simulateTypingIn $inputor 17 | simulateChoose $inputor 18 | 19 | @simulateTypingIn = ($inputor, flag, pos=31) -> 20 | $inputor.data("atwho").setContextFor flag || "@" 21 | oDocument = $inputor[0].ownerDocument 22 | oWindow = oDocument.defaultView || oDocument.parentWindow 23 | if $inputor.attr('contentEditable') == 'true' && oWindow.getSelection 24 | $inputor.focus() 25 | sel = oWindow.getSelection() 26 | range = oDocument.createRange() 27 | range.setStart $inputor.contents().get(0), pos 28 | range.setEnd $inputor.contents().get(0), pos 29 | range.collapse false 30 | sel.removeAllRanges() 31 | sel.addRange(range) 32 | else 33 | $inputor.caret('pos', pos) 34 | $inputor.trigger("keyup") 35 | 36 | @simulateChoose = ($inputor) -> 37 | e = $.Event("keydown", keyCode: KEY_CODE.ENTER) 38 | $inputor.trigger(e) 39 | 40 | @getAppOf = ($inputor, at = "@") -> 41 | $inputor.data('atwho').setContextFor(at) 42 | -------------------------------------------------------------------------------- /spec/javascripts/content_editable.spec.coffee: -------------------------------------------------------------------------------- 1 | describe "content editable", -> 2 | $inputor = null 3 | app = null 4 | $ = jQuery 5 | 6 | beforeEach -> 7 | loadFixtures "inputors.html" 8 | $inputor = $("#editable").atwho 9 | at: "@", 10 | data: ["Jobs"] 11 | editableAtwhoQueryAttrs: {class: "hello", "data-editor-verified":true} 12 | app = getAppOf $inputor 13 | 14 | afterEach -> 15 | $inputor.atwho 'destroy' 16 | 17 | it "can insert content", -> 18 | triggerAtwhoAt $inputor 19 | expect($inputor.text()).toContain('@Jobs') 20 | 21 | it "insert by click", -> 22 | simulateTypingIn $inputor 23 | $inputor.blur() 24 | app.controller().view.$el.find('ul').children().first().trigger('click') 25 | expect($inputor.text()).toContain('@Jobs') 26 | 27 | it "unwrap span.atwho-query after match failed", -> 28 | simulateTypingIn $inputor 29 | expect $('.atwho-query').length 30 | .toBe 1 31 | $('.atwho-query').html "@J " 32 | simulateTypingIn $inputor, "@", 3 33 | expect $('.atwho-query').length 34 | .toBe 0 35 | 36 | it "wrap span.atwho-query with customize attrs", -> 37 | # for #235 38 | simulateTypingIn $inputor 39 | expect $('.atwho-query').data('editor-verified') 40 | .toBe true 41 | -------------------------------------------------------------------------------- /spec/javascripts/view.spec.coffee: -------------------------------------------------------------------------------- 1 | 2 | describe "views", -> 3 | 4 | $inputor = null 5 | app = null 6 | $ = jQuery 7 | beforeEach -> 8 | loadFixtures "inputors.html" 9 | $inputor = $ "#inputor" 10 | .atwho at: "@", data: fixtures["names"] 11 | app = getAppOf $inputor 12 | afterEach -> 13 | $inputor.atwho 'destroy' 14 | 15 | describe "issues", -> 16 | controller = null 17 | callbacks = null 18 | 19 | beforeEach -> 20 | controller = app.controller() 21 | callbacks = $.fn.atwho.default.callbacks 22 | simulateTypingIn $inputor 23 | 24 | it "selected no highlight(.cur); github issues#234", -> 25 | simulateTypingIn $inputor 26 | expect targetLi = controller.view.$el.find('ul li:last') 27 | .not.toHaveClass 'cur' 28 | spyOn controller.view, "choose" 29 | .and.callThrough() 30 | targetLi.trigger clickEvent = $.Event("click.atwho-view") 31 | expect targetLi 32 | .toHaveClass 'cur' 33 | 34 | it "only hides on scroll if scrollTop is changed (github issue #305)", -> 35 | simulateTypingIn $inputor 36 | expect(controller.view.visible()).toBe true 37 | $inputor.trigger 'scroll' 38 | expect(controller.view.visible()).toBe true 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "at.js", 3 | "main": "dist/js/jquery.atwho.js", 4 | "author": { 5 | "name": "chord.luo", 6 | "email": "chord.luo@gmail.com" 7 | }, 8 | "homepage": "http://ichord.github.com/At.js", 9 | "license": "MIT", 10 | "version": "1.5.7", 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/ichord/At.js" 14 | }, 15 | "engines": { 16 | "node": ">= 0.6.0" 17 | }, 18 | "scripts": { 19 | "test": "gulp test" 20 | }, 21 | "devDependencies": { 22 | "gulp": "^3.9.0", 23 | "gulp-bump": "^1.0.0", 24 | "gulp-coffee": "^2.3.1", 25 | "gulp-concat": "^2.6.0", 26 | "gulp-cssmin": "^0.1.7", 27 | "gulp-debug": "^2.1.2", 28 | "gulp-header": "^1.7.1", 29 | "gulp-jasmine": "^2.2.1", 30 | "gulp-jasmine-phantom": "^2.0.1", 31 | "gulp-rename": "^1.2.2", 32 | "gulp-uglify": "^1.5.1", 33 | "gulp-umd": "^0.2.0", 34 | "gulp-util": "^3.0.7", 35 | "jasmine-ajax": "^3.2.0", 36 | "jasmine-jquery": "^2.1.1", 37 | "jquery": ">=1.7.0", 38 | "phantomjs": "^1.9.19" 39 | }, 40 | "spm": { 41 | "main": "dist/js/jquery.atwho.js", 42 | "dependencies": { 43 | "jquery": ">=1.7.2", 44 | "caret.js": "~0.2.2" 45 | }, 46 | "ignore": [ 47 | "examples", 48 | "spec", 49 | "src" 50 | ] 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/jquery.atwho.css: -------------------------------------------------------------------------------- 1 | .atwho-view { 2 | position:absolute; 3 | top: 0; 4 | left: 0; 5 | display: none; 6 | margin-top: 18px; 7 | background: white; 8 | color: black; 9 | border: 1px solid #DDD; 10 | border-radius: 3px; 11 | box-shadow: 0 0 5px rgba(0,0,0,0.1); 12 | min-width: 120px; 13 | z-index: 11110 !important; 14 | } 15 | 16 | .atwho-view .atwho-header { 17 | padding: 5px; 18 | margin: 5px; 19 | cursor: pointer; 20 | border-bottom: solid 1px #eaeff1; 21 | color: #6f8092; 22 | font-size: 11px; 23 | font-weight: bold; 24 | } 25 | 26 | .atwho-view .atwho-header .small { 27 | color: #6f8092; 28 | float: right; 29 | padding-top: 2px; 30 | margin-right: -5px; 31 | font-size: 12px; 32 | font-weight: normal; 33 | } 34 | 35 | .atwho-view .atwho-header:hover { 36 | cursor: default; 37 | } 38 | 39 | .atwho-view .cur { 40 | background: #3366FF; 41 | color: white; 42 | } 43 | .atwho-view .cur small { 44 | color: white; 45 | } 46 | .atwho-view strong { 47 | color: #3366FF; 48 | } 49 | .atwho-view .cur strong { 50 | color: white; 51 | font:bold; 52 | } 53 | .atwho-view ul { 54 | /* width: 100px; */ 55 | list-style:none; 56 | padding:0; 57 | margin:auto; 58 | max-height: 200px; 59 | overflow-y: auto; 60 | } 61 | .atwho-view ul li { 62 | display: block; 63 | padding: 5px 10px; 64 | border-bottom: 1px solid #DDD; 65 | cursor: pointer; 66 | /* border-top: 1px solid #C8C8C8; */ 67 | } 68 | .atwho-view small { 69 | font-size: smaller; 70 | color: #777; 71 | font-weight: normal; 72 | } 73 | -------------------------------------------------------------------------------- /dist/css/jquery.atwho.css: -------------------------------------------------------------------------------- 1 | .atwho-view { 2 | position:absolute; 3 | top: 0; 4 | left: 0; 5 | display: none; 6 | margin-top: 18px; 7 | background: white; 8 | color: black; 9 | border: 1px solid #DDD; 10 | border-radius: 3px; 11 | box-shadow: 0 0 5px rgba(0,0,0,0.1); 12 | min-width: 120px; 13 | z-index: 11110 !important; 14 | } 15 | 16 | .atwho-view .atwho-header { 17 | padding: 5px; 18 | margin: 5px; 19 | cursor: pointer; 20 | border-bottom: solid 1px #eaeff1; 21 | color: #6f8092; 22 | font-size: 11px; 23 | font-weight: bold; 24 | } 25 | 26 | .atwho-view .atwho-header .small { 27 | color: #6f8092; 28 | float: right; 29 | padding-top: 2px; 30 | margin-right: -5px; 31 | font-size: 12px; 32 | font-weight: normal; 33 | } 34 | 35 | .atwho-view .atwho-header:hover { 36 | cursor: default; 37 | } 38 | 39 | .atwho-view .cur { 40 | background: #3366FF; 41 | color: white; 42 | } 43 | .atwho-view .cur small { 44 | color: white; 45 | } 46 | .atwho-view strong { 47 | color: #3366FF; 48 | } 49 | .atwho-view .cur strong { 50 | color: white; 51 | font:bold; 52 | } 53 | .atwho-view ul { 54 | /* width: 100px; */ 55 | list-style:none; 56 | padding:0; 57 | margin:auto; 58 | max-height: 200px; 59 | overflow-y: auto; 60 | } 61 | .atwho-view ul li { 62 | display: block; 63 | padding: 5px 10px; 64 | border-bottom: 1px solid #DDD; 65 | cursor: pointer; 66 | /* border-top: 1px solid #C8C8C8; */ 67 | } 68 | .atwho-view small { 69 | font-size: smaller; 70 | color: #777; 71 | font-weight: normal; 72 | } 73 | -------------------------------------------------------------------------------- /examples/ueditor.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | ueditor 13 | 14 | 15 | 18 | 19 | 20 | 21 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | ### Code style 4 | 5 | **Two** space indent 6 | 7 | ### Modifying the code 8 | First, ensure that you have the latest [Node.js](http://nodejs.org/) and [npm](http://npmjs.org/) installed. 9 | 10 | Test that gulp is installed globally by running `grunt -v` at the command-line. If gulp isn't installed globally, run `npm install -g gulp` to install the latest version. 11 | 12 | * Fork and clone the repo. 13 | * Run `npm install` and `bower install` to install all dev dependencies (including grunt). 14 | * Modify the `*.coffee` file. 15 | * Run `gulp` to build this project. 16 | 17 | Assuming that you don't see any red, you're ready to go. Just be sure to run `gulp` after making any changes, to ensure that nothing is broken. 18 | 19 | ### Submitting pull requests 20 | 21 | 1. Create a new branch, please don't work in your `master` branch directly. 22 | 1. Add failing tests for the change you want to make. Run `gulp` to see the tests fail. 23 | 1. Fix stuff. 24 | 1. Run `gulp` to see if the tests pass. Repeat steps 2-4 until done. 25 | 1. Open `_SpecRunner.html` unit test file(s) in actual browser to ensure tests pass everywhere. 26 | 1. Update the documentation to reflect any changes. 27 | 1. Push to your fork and submit a pull request. 28 | 29 | ### notes 30 | 31 | Please don't edit files in the `dist` subdirectory and *.js files in `src` as they are generated via gulp. 32 | You'll find source code in the `src` subdirectory! 33 | use `bower install` or `component install` to install dependencies first. 34 | 35 | 36 | ### PhantomJS 37 | While gulp can run the included unit tests via [PhantomJS](http://phantomjs.org/), this shouldn't be considered a substitute for the real thing. Please be sure to test the `_SpecRunner.html` unit test file(s) in _actual_ browsers. 38 | -------------------------------------------------------------------------------- /src/api.coffee: -------------------------------------------------------------------------------- 1 | Api = 2 | # load a flag's data 3 | # 4 | # @params at[String] the flag 5 | # @params data [Array] data to storage. 6 | load: (at, data) -> c.model.load data if c = this.controller(at) 7 | isSelecting: () -> !!this.controller()?.view.visible() 8 | hide: () -> this.controller()?.view.hide() 9 | reposition: () -> 10 | if c = this.controller() 11 | c.view.reposition(c.rect()) 12 | setIframe: (iframe, asRoot) -> this.setupRootElement(iframe, asRoot); null; 13 | run: -> this.dispatch() 14 | destroy: -> 15 | this.shutdown() 16 | @$inputor.data('atwho', null) 17 | 18 | $.fn.atwho = (method) -> 19 | _args = arguments 20 | result = null 21 | this.filter('textarea, input, [contenteditable=""], [contenteditable=true]').each -> 22 | if not app = ($this = $ this).data "atwho" 23 | $this.data 'atwho', (app = new App this) 24 | if typeof method is 'object' || !method 25 | app.reg method.at, method 26 | else if Api[method] and app 27 | result = Api[method].apply app, Array::slice.call(_args, 1) 28 | else 29 | $.error "Method #{method} does not exist on jQuery.atwho" 30 | if result? then result else this 31 | 32 | $.fn.atwho.default = 33 | at: undefined 34 | alias: undefined 35 | data: null 36 | displayTpl: "
  • ${name}
  • " 37 | insertTpl: "${atwho-at}${name}" 38 | headerTpl: null 39 | callbacks: DEFAULT_CALLBACKS 40 | functionOverrides: {} 41 | searchKey: "name" 42 | suffix: undefined 43 | hideWithoutSuffix: no 44 | startWithSpace: yes 45 | acceptSpaceBar: false 46 | highlightFirst: yes 47 | limit: 5 48 | maxLen: 20 49 | minLen: 0 50 | displayTimeout: 300 51 | delay: null 52 | spaceSelectsMatch: no 53 | tabSelectsMatch: yes 54 | editableAtwhoQueryAttrs: {} 55 | scrollDuration: 150 56 | suspendOnComposing: true 57 | lookUpOnClick: true 58 | 59 | $.fn.atwho.debug = false 60 | -------------------------------------------------------------------------------- /src/model.coffee: -------------------------------------------------------------------------------- 1 | # Class to process data 2 | class Model 3 | 4 | constructor: (@context) -> 5 | @at = @context.at 6 | # NOTE: bind data storage to inputor maybe App class can handle it. 7 | @storage = @context.$inputor 8 | 9 | destroy: -> 10 | @storage.data(@at, null) 11 | 12 | saved: -> 13 | this.fetch() > 0 14 | 15 | # fetch data from storage by query. 16 | # will invoke `callback` to return data 17 | # 18 | # @param query [String] catched string for searching 19 | # @param callback [Function] for receiving data 20 | query: (query, callback) -> 21 | data = this.fetch() 22 | searchKey = @context.getOpt("searchKey") 23 | data = @context.callbacks('filter').call(@context, query, data, searchKey) || [] 24 | _remoteFilter = @context.callbacks('remoteFilter') 25 | if data.length > 0 or (!_remoteFilter and data.length == 0) 26 | callback data 27 | else 28 | _remoteFilter.call(@context, query, callback) 29 | 30 | # get or set current data which would be shown on the list view. 31 | # 32 | # @param data [Array] set data 33 | # @return [Array|undefined] current data that are showing on the list view. 34 | fetch: -> 35 | @storage.data(@at) || [] 36 | 37 | # save special flag's data to storage 38 | # 39 | # @param data [Array] data to save 40 | save: (data) -> 41 | @storage.data @at, @context.callbacks("beforeSave").call(@context, data || []) 42 | 43 | # load data. It wouldn't load for a second time if it has been loaded. 44 | # 45 | # @param data [Array] data to load 46 | load: (data) -> 47 | this._load(data) unless this.saved() or not data 48 | 49 | reload: (data) -> 50 | this._load(data) 51 | 52 | # load data from local or remote with callback 53 | # 54 | # @param data [Array|String] data to load. 55 | _load: (data) -> 56 | if typeof data is "string" 57 | $.ajax(data, dataType: "json").done (data) => this.save(data) 58 | else 59 | this.save data 60 | -------------------------------------------------------------------------------- /examples/medium-editor.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | At.js 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 25 | 26 | 27 | 28 |
    29 |
    30 |

    Example for medium-editor

    31 |
    32 |
    33 |
    Easy! You should check out MoxieManager!
    34 | 39 | 40 |
    41 |
    42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /examples/tinyMCE.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | At.js 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 34 | 35 | 36 | 37 |
    38 |
    39 |

    Example for tinyMCE editor

    40 |
    41 |
    42 | 43 | 48 | 49 |
    50 |
    51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /examples/cross_document/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | At.js 6 | 76 | 77 | 78 | 79 |
    80 |
    81 |

    At.js

    82 |
    83 |
    84 |

    Cross-Document

    85 | 86 | 87 |
    88 |
    89 | 90 | 91 | -------------------------------------------------------------------------------- /specRunner.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/textareaController.coffee: -------------------------------------------------------------------------------- 1 | class TextareaController extends Controller 2 | # Catch query string behind the at char 3 | # 4 | # @return [Hash] Info of the query. Look likes this: {'text': "hello", 'headPos': 0, 'endPos': 0} 5 | catchQuery: -> 6 | content = @$inputor.val() 7 | caretPos = @$inputor.caret('pos', {iframe: @app.iframe}) 8 | subtext = content.slice(0, caretPos) 9 | query = this.callbacks("matcher").call(this, @at, subtext, this.getOpt('startWithSpace'), @getOpt("acceptSpaceBar")) 10 | isString = typeof query is 'string' 11 | 12 | return if isString and query.length < this.getOpt('minLen', 0) 13 | 14 | if isString and query.length <= this.getOpt('maxLen', 20) 15 | start = caretPos - query.length 16 | end = start + query.length 17 | @pos = start 18 | query = {'text': query, 'headPos': start, 'endPos': end} 19 | this.trigger "matched", [@at, query.text] 20 | else 21 | query = null 22 | @view.hide() 23 | 24 | @query = query 25 | 26 | # Get offset of current at char(`flag`) 27 | # 28 | # @return [Hash] the offset which look likes this: {top: y, left: x, bottom: bottom} 29 | rect: -> 30 | return if not c = @$inputor.caret('offset', @pos - 1, {iframe: @app.iframe}) 31 | if @app.iframe and not @app.iframeAsRoot 32 | iframeOffset = $(@app.iframe).offset() 33 | c.left += iframeOffset.left 34 | c.top += iframeOffset.top 35 | scaleBottom = if @app.document.selection then 0 else 2 36 | {left: c.left, top: c.top, bottom: c.top + c.height + scaleBottom} 37 | 38 | # Insert value of `data-value` attribute of chosen item into inputor 39 | # 40 | # @param content [String] string to insert 41 | insert: (content, $li) -> 42 | $inputor = @$inputor 43 | source = $inputor.val() 44 | startStr = source.slice 0, Math.max(@query.headPos - @at.length, 0) 45 | suffix = if (suffix = @getOpt 'suffix') == "" then suffix else suffix or " " 46 | content += suffix 47 | text = "#{startStr}#{content}#{source.slice @query['endPos'] || 0}" 48 | $inputor.val text 49 | $inputor.caret('pos', startStr.length + content.length, {iframe: @app.iframe}) 50 | $inputor.focus() unless $inputor.is ':focus' 51 | $inputor.change() 52 | -------------------------------------------------------------------------------- /examples/hashtags.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | At.js 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 35 | 44 | 45 | 46 |
    47 |
    48 |

    Type `#` to autocomplete tags

    49 |
    50 |
    51 |
    52 | 57 | 58 |
    59 |
    60 | 61 | 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **An autocompletion library to autocomplete mentions, smileys etc. just like on Github!** 2 | [![Build Status](https://travis-ci.org/ichord/At.js.png)](https://travis-ci.org/ichord/At.js) 3 | 4 | #### Notice 5 | 6 | At.js now **depends on** [Caret.js](https://github.com/ichord/Caret.js). 7 | Please read [**CHANGELOG.md**](CHANGELOG.md) for more details if you are going to update to new version. 8 | 9 | ### Demo 10 | http://ichord.github.com/At.js 11 | 12 | ### Documentation 13 | https://github.com/ichord/At.js/wiki 14 | 15 | ### Compatibility 16 | 17 | * `textarea` - Chrome, Safari, Firefox, IE7+ (maybe IE6) 18 | * `contentEditable` - Chrome, Safari, Firefox, IE9+ 19 | 20 | ### Features Preview 21 | 22 | * Support IE 7+ for **textarea**. 23 | * Supports HTML5 [**contentEditable**](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Content_Editable) elements (NOT including IE 8) 24 | * Can listen to any character and not just '@'. Can set up multiple listeners for different characters with different behavior and data 25 | * Listener events can be bound to multiple inputors. 26 | * Format returned data using templates 27 | * Keyboard controls in addition to mouse 28 | - `Tab` or `Enter` keys select the value 29 | - `Up` and `Down` navigate between values (and `Ctrl-P` and `Ctrl-N` also) 30 | - `Right` and `left` will re-search the keyword. 31 | * Custom data handlers and template renderers using a group of configurable callbacks 32 | * Supports AMD 33 | 34 | ### Requirements 35 | 36 | * jQuery >= 1.7.0. 37 | * [Caret.js](https://github.com/ichord/Caret.js) 38 | (You can use `Component` or `Bower` to install it.) 39 | 40 | ### Integrating with your Application 41 | 42 | Simply include the following files in your HTML and you are good to go. 43 | 44 | ```html 45 | 46 | 47 | 48 | 49 | ``` 50 | 51 | ```javascript 52 | $('#inputor').atwho({ 53 | at: "@", 54 | data:['Peter', 'Tom', 'Anne'] 55 | }) 56 | ``` 57 | 58 | #### Bower & Component 59 | For installing using Bower you can use `jquery.atwho` and for Component please use `ichord/At.js`. 60 | 61 | #### Rails 62 | You can include At.js in your `Rails` application using the gem [jquery-atwho-rails](https://github.com/ichord/jquery-atwho-rails). 63 | 64 | ### Core Team Members 65 | 66 | * [@ichord](https://twitter.com/_ichord) (twitter) 67 | 68 | -------------------------------------------------------------------------------- /spec/javascripts/apis.spec.coffee: -------------------------------------------------------------------------------- 1 | $inputor = null 2 | app = null 3 | 4 | describe "api", -> 5 | $ = jQuery 6 | 7 | beforeEach -> 8 | loadFixtures("inputors.html") 9 | $inputor = $("#inputor").atwho at: "@", data: fixtures["names"] 10 | app = getAppOf $inputor 11 | afterEach -> 12 | $inputor.atwho 'destroy' 13 | 14 | describe "inner", -> 15 | 16 | controller = null 17 | callbacks = null 18 | 19 | beforeEach -> 20 | jasmine.Ajax.install() 21 | controller = app.controller() 22 | 23 | afterEach -> 24 | jasmine.Ajax.uninstall() 25 | 26 | it "can get current data", -> 27 | simulateTypingIn $inputor 28 | expect(controller.model.fetch().length).toBe 24 29 | 30 | it "can save current data", -> 31 | simulateTypingIn $inputor 32 | data = [{id: 1, name: "one"}, {id: 2, name: "two"}] 33 | controller.model.save(data) 34 | expect(controller.model.fetch().length).toBe 2 35 | 36 | it "don't change data setting while using remote filter", -> 37 | $inputor.atwho 38 | at: "@" 39 | data: "/atwho.json" 40 | 41 | simulateTypingIn $inputor 42 | 43 | request = jasmine.Ajax.requests.mostRecent() 44 | response_data = [{"name":"Jacob"}, {"name":"Joshua"}, {"name":"Jayden"}] 45 | request.respondWith 46 | status: 200 47 | responseText: JSON.stringify(response_data) 48 | 49 | expect(controller.getOpt("data")).toBe "/atwho.json" 50 | expect(controller.model.fetch().length).toBe 3 51 | 52 | 53 | describe "public", -> 54 | controller = null 55 | data = [] 56 | 57 | beforeEach -> 58 | controller = app.controller() 59 | data = [ 60 | {one: 1} 61 | {two: 2} 62 | {three: 3} 63 | ] 64 | 65 | it "can load data for special flag", -> 66 | $inputor.atwho "load", "@", data 67 | expect(controller.model.fetch().length).toBe data.length 68 | 69 | it "can load data with alias", -> 70 | $inputor.atwho at: "@", alias: "at" 71 | $inputor.atwho "load", "at", data 72 | expect(controller.model.fetch().length).toBe data.length 73 | 74 | it "can run it handly", -> 75 | app.setContextFor null 76 | $inputor.caret('pos', 31) 77 | $inputor.atwho "run" 78 | 79 | expect(app.controller().view.$el).not.toBeHidden() 80 | 81 | it 'destroy', -> 82 | $inputor.atwho at: "~" 83 | view_id = app.controller('~').view.$el.attr('id') 84 | $inputor.atwho 'destroy' 85 | expect($("##{view_id}").length).toBe 0 86 | expect($inputor.data('atwho')).toBe null 87 | expect($inputor.data('~')).toBe null 88 | 89 | it 'isSelecting correctness', -> 90 | expect($inputor.atwho 'isSelecting').toBe false 91 | simulateTypingIn $inputor 92 | expect($inputor.atwho 'isSelecting').toBe true 93 | -------------------------------------------------------------------------------- /spec/javascripts/custom_callbacks.spec.coffee: -------------------------------------------------------------------------------- 1 | describe "custom callbacks", -> 2 | $inputor = null 3 | $ = jQuery 4 | 5 | beforeEach -> 6 | loadFixtures("inputors.html") 7 | afterEach -> 8 | $inputor.atwho 'destroy' 9 | 10 | describe "remoteFilter()", -> 11 | it "only renders the view for data from the latest lookup", -> 12 | callbackList = [] 13 | 14 | remoteFilter = jasmine.createSpy("remoteFilter").and.callFake (_, cb) -> 15 | callbackList.push cb 16 | 17 | $inputor = $("#inputor").atwho({ 18 | at: "@", 19 | data: [], 20 | callbacks: { 21 | remoteFilter 22 | } 23 | }) 24 | $inputor.val('@foo') 25 | 26 | app = getAppOf $inputor 27 | controller = app.controller() 28 | spyOn(controller, 'renderView') 29 | 30 | simulateTypingIn $inputor 31 | expect(remoteFilter).toHaveBeenCalled() 32 | simulateTypingIn $inputor 33 | expect(callbackList.length).toBeGreaterThan(1) 34 | while callbackList.length > 1 35 | callbackList.shift()(['no renders']) 36 | expect(controller.renderView).not.toHaveBeenCalled() 37 | 38 | callbackList.shift()(['render']) 39 | expect(controller.renderView).toHaveBeenCalled() 40 | 41 | it "does not attempt to render the view after query has been cleared", -> 42 | remoteFilterCb = null 43 | 44 | remoteFilter = jasmine.createSpy("remoteFilter").and.callFake (_, cb) -> 45 | remoteFilterCb = cb 46 | 47 | $inputor = $("#inputor").atwho({ 48 | at: "@", 49 | data: [], 50 | callbacks: { 51 | remoteFilter 52 | } 53 | }) 54 | 55 | app = getAppOf $inputor 56 | controller = app.controller() 57 | spyOn controller, 'renderView' 58 | 59 | simulateTypingIn $inputor 60 | expect(remoteFilter).toHaveBeenCalled() 61 | $inputor.val '' 62 | simulateTypingIn $inputor 63 | expect(remoteFilter.calls.count()).toEqual(1) 64 | remoteFilterCb ['should not render'] 65 | expect(controller.renderView).not.toHaveBeenCalled() 66 | 67 | it "does not attempt to render the view after focus has been lost", -> 68 | remoteFilterCb = null 69 | 70 | remoteFilter = jasmine.createSpy("remoteFilter").and.callFake (_, cb) -> 71 | remoteFilterCb = cb 72 | 73 | $inputor = $("#inputor").atwho({ 74 | at: "@", 75 | data: [], 76 | callbacks: { 77 | remoteFilter 78 | } 79 | }) 80 | 81 | app = getAppOf $inputor 82 | controller = app.controller() 83 | spyOn controller, 'renderView' 84 | 85 | simulateTypingIn $inputor 86 | expect(remoteFilter).toHaveBeenCalled() 87 | $inputor.blur(); 88 | remoteFilterCb ['should not render'] 89 | expect(controller.renderView).not.toHaveBeenCalled() 90 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'), 2 | coffee = require('gulp-coffee'), 3 | concat = require('gulp-concat'), 4 | umd = require('gulp-umd'), 5 | uglify = require('gulp-uglify'), 6 | rename = require("gulp-rename"), 7 | cssmin = require('gulp-cssmin'), 8 | jasmine = require('gulp-jasmine-phantom'), 9 | bump = require('gulp-bump'), 10 | header = require('gulp-header'), 11 | debug = require('gulp-debug'), 12 | util = require('gulp-util'); 13 | 14 | var name = 'jquery.atwho'; 15 | 16 | gulp.task('coffee', function() { 17 | gulp.src('src/*.coffee') 18 | .pipe(coffee({bare: true}).on('error', util.log)) 19 | .pipe(gulp.dest('./build/js')); 20 | }); 21 | 22 | gulp.task('concat', function() { 23 | fileList = [ 24 | 'build/js/default.js', 25 | 'build/js/app.js', 26 | 'build/js/controller.js', 27 | 'build/js/textareaController.js', 28 | 'build/js/editableController.js', 29 | 'build/js/model.js', 30 | 'build/js/view.js', 31 | 'build/js/api.js' 32 | ] 33 | gulp.src(fileList) 34 | .pipe(concat(name + ".js")) 35 | .pipe(gulp.dest('build')); 36 | }); 37 | 38 | gulp.task('umd', function() { 39 | gulp.src('build/' + name + ".js") 40 | .pipe(umd({template: "umd.template.js"})) 41 | .pipe(gulp.dest('build/js')); 42 | }); 43 | 44 | gulp.task('bump', function() { 45 | gulp.src(['bower.json', 'component.json', 'package.json']) 46 | .pipe(bump({version: "1.5.7"})) 47 | .pipe(gulp.dest('./')); 48 | }); 49 | 50 | gulp.task("mark", function() { 51 | var pkg = require('./package.json'); 52 | var banner = ['/**', 53 | ' * <%= pkg.name %> - <%= pkg.version %>', 54 | ' * Copyright (c) <%= year %> <%= pkg.author.name %> <<%= pkg.author.email %>>;', 55 | ' * Homepage: <%= pkg.homepage %>', 56 | ' * License: <%= pkg.license %>', 57 | ' */', 58 | ''].join('\n'); 59 | 60 | gulp.src('build/js/' + name + '.js') 61 | .pipe(header(banner, { pkg : pkg, year: (new Date).getFullYear()})) 62 | .pipe(gulp.dest('dist/js/')) 63 | }); 64 | 65 | gulp.task('compress', function() { 66 | gulp.src('dist/js/' + name + '.js') 67 | .pipe(uglify()) 68 | .pipe(rename({suffix: '.min'})) 69 | .pipe(gulp.dest('dist/js')); 70 | 71 | gulp.src('src/jquery.atwho.css').pipe(gulp.dest('dist/css')) 72 | gulp.src('dist/css/' + name + '.css') 73 | .pipe(cssmin()) 74 | .pipe(rename({suffix: '.min'})) 75 | .pipe(gulp.dest('dist/css')); 76 | }); 77 | 78 | gulp.task('test', function () { 79 | gulp.src('spec/**/*.coffee') 80 | .pipe(coffee({bare: true}).on('error', util.log)) 81 | .pipe(debug({title: "compiled specs"})) 82 | .pipe(gulp.dest('spec/build')) 83 | 84 | gulp.src('spec/build/javascripts/*.spec.js') 85 | .pipe(jasmine({ 86 | integration: true, 87 | specHtml: "specRunner.html" 88 | /* TODO: have to add css to spec 89 | vendor: [ 90 | 'bower_components/jquery/dist/jquery.js', 91 | 'bower_components/Caret.js/dist/jquery.caret.js', 92 | 'dist/js/jquery.atwho.js', 93 | 'node_modules/jasmine-jquery/lib/*.js', 94 | 'node_modules/jasmine-ajax/lib/*.js', 95 | 'spec/helpers/*.js', 96 | 'spec/build/spec_helper.js' 97 | ], 98 | */ 99 | })); 100 | }); 101 | 102 | gulp.task('compile', ['coffee', 'umd', 'concat']); 103 | gulp.task('default', ['compile', 'bump', 'mark', 'compress']); 104 | -------------------------------------------------------------------------------- /spec/javascripts/events.spec.coffee: -------------------------------------------------------------------------------- 1 | 2 | describe "events", -> 3 | 4 | $inputor = null 5 | app = null 6 | $ = jQuery 7 | 8 | beforeEach -> 9 | loadFixtures "inputors.html" 10 | $inputor = $("#inputor").atwho at: "@", data: fixtures["names"] 11 | app = getAppOf $inputor 12 | afterEach -> 13 | $inputor.atwho 'destroy' 14 | 15 | describe "inner", -> 16 | controller = null 17 | callbacks = null 18 | 19 | beforeEach -> 20 | controller = app.controller() 21 | callbacks = $.fn.atwho.default.callbacks 22 | simulateTypingIn $inputor 23 | 24 | it "trigger esc", -> 25 | esc_event = $.Event("keyup.atwhoInner", keyCode: KEY_CODE.ESC) 26 | $inputor.trigger(esc_event) 27 | expect(controller.view.visible()).toBe(false) 28 | 29 | it "trigger tab", -> 30 | spyOn(callbacks, "beforeInsert").and.callThrough() 31 | tab_event = $.Event("keydown.atwhoInner", keyCode: KEY_CODE.TAB) 32 | $inputor.trigger(tab_event) 33 | expect(controller.view.visible()).toBe(false) 34 | expect(callbacks.beforeInsert).toHaveBeenCalled() 35 | 36 | it "trigger enter", -> 37 | spyOn(callbacks, "beforeInsert").and.callThrough() 38 | enter_event = $.Event("keydown.atwhoInner", keyCode: KEY_CODE.ENTER) 39 | $inputor.trigger(enter_event) 40 | expect(callbacks.beforeInsert).toHaveBeenCalled() 41 | 42 | it "trigger up", -> 43 | spyOn(controller.view, "prev").and.callThrough() 44 | up_event = $.Event("keydown.atwhoInner", keyCode: KEY_CODE.UP) 45 | $inputor.trigger(up_event) 46 | expect(controller.view.prev).toHaveBeenCalled() 47 | 48 | it "trigger down", -> 49 | spyOn(controller.view, "next").and.callThrough() 50 | down_event = $.Event("keydown.atwhoInner", keyCode: KEY_CODE.DOWN) 51 | $inputor.trigger(down_event) 52 | expect(controller.view.next).toHaveBeenCalled() 53 | 54 | it "trigger up(ctrl + p)", -> 55 | spyOn(controller.view, "prev").and.callThrough() 56 | up_event = $.Event("keydown.atwhoInner", keyCode: KEY_CODE.P, ctrlKey: true) 57 | $inputor.trigger(up_event) 58 | expect(controller.view.prev).toHaveBeenCalled() 59 | 60 | it "trigger down(ctrl + n)", -> 61 | spyOn(controller.view, "next").and.callThrough() 62 | down_event = $.Event("keydown.atwhoInner", keyCode: KEY_CODE.N, ctrlKey: true) 63 | $inputor.trigger(down_event) 64 | expect(controller.view.next).toHaveBeenCalled() 65 | 66 | it "trigger p", -> 67 | spyOn(controller.view, "prev").and.callThrough() 68 | p_event = $.Event("keydown.atwhoInner", keyCode: KEY_CODE.P, ctrlKey: false) 69 | $inputor.trigger(p_event) 70 | expect(controller.view.prev).not.toHaveBeenCalled() 71 | 72 | it "trigger n", -> 73 | spyOn(controller.view, "prev").and.callThrough() 74 | n_event = $.Event("keydown.atwhoInner", keyCode: KEY_CODE.N, ctrlKey: false) 75 | $inputor.trigger(n_event) 76 | expect(controller.view.prev).not.toHaveBeenCalled() 77 | 78 | describe "atwho", -> 79 | 80 | it "trigger matched", -> 81 | matched_event = spyOnEvent($inputor, "matched.atwho") 82 | triggerAtwhoAt $inputor 83 | expect(matched_event).toHaveBeenTriggered() 84 | 85 | it "trigger inserted", -> 86 | choose_event = spyOnEvent($inputor, "inserted.atwho") 87 | triggerAtwhoAt $inputor 88 | expect(choose_event).toHaveBeenTriggered() 89 | 90 | it "trigger reposition", -> 91 | reposition_event = spyOnEvent($inputor, "reposition.atwho") 92 | triggerAtwhoAt $inputor 93 | expect(reposition_event).toHaveBeenTriggered() 94 | 95 | it "trigger a special matched for @ with alias", -> 96 | $inputor.atwho 97 | at: "@" 98 | alias: "at-memtions" 99 | 100 | event = spyOnEvent($inputor, "matched-at-memtions.atwho") 101 | triggerAtwhoAt $inputor 102 | expect(event).toHaveBeenTriggered() 103 | 104 | it "trigger beforeDestroy", -> 105 | destroy_event = spyOnEvent($inputor, "beforeDestroy.atwho") 106 | $inputor.atwho 'destroy' 107 | expect(destroy_event).toHaveBeenTriggered() 108 | -------------------------------------------------------------------------------- /spec/javascripts/settings.spec.coffee: -------------------------------------------------------------------------------- 1 | describe "settings", -> 2 | 3 | $inputor = null 4 | app = null 5 | controller = null 6 | callbacks = null 7 | $ = jQuery 8 | 9 | beforeEach -> 10 | loadFixtures("inputors.html") 11 | $inputor = $("#inputor").atwho at: "@", data: fixtures["names"] 12 | app = getAppOf $inputor 13 | controller = app.controller() 14 | callbacks = $.fn.atwho.default.callbacks 15 | afterEach -> 16 | $inputor.atwho 'destroy' 17 | 18 | it "update common settings", -> 19 | func = () -> 20 | $.noop 21 | old = $.extend {}, $.fn.atwho.default.callbacks 22 | $.fn.atwho.default.callbacks.filter = func 23 | $.fn.atwho.default.limit = 8 24 | $inputor = $("").atwho at: "@" 25 | controller = $inputor.data('atwho').setContextFor("@").controller() 26 | expect(controller.callbacks("filter")).toBe func 27 | expect(controller.getOpt("limit")).toBe 8 28 | $.extend $.fn.atwho.default.callbacks, old 29 | 30 | it "setting empty at", -> 31 | $inputor = $("").atwho at: "" 32 | controller = $inputor.data('atwho').controller "" 33 | expect(controller).toBeDefined() 34 | 35 | it "update specific settings", -> 36 | $inputor.atwho at: "@", limit: 3 37 | expect(controller.setting.limit).toBe(3) 38 | 39 | it "update callbacks", -> 40 | filter = jasmine.createSpy("custom filter") 41 | spyOn(callbacks, "filter") 42 | $inputor.atwho 43 | at: "@" 44 | callbacks: 45 | filter: filter 46 | 47 | triggerAtwhoAt $inputor 48 | expect(filter).toHaveBeenCalled() 49 | expect(callbacks.filter).not.toHaveBeenCalled() 50 | 51 | it "setting timeout", -> 52 | jasmine.clock().install() 53 | $inputor.atwho 54 | at: "@" 55 | displayTimeout: 500 56 | 57 | simulateTypingIn $inputor 58 | $inputor.trigger "blur" 59 | view = controller.view.$el 60 | 61 | expect(view).not.toBeHidden() 62 | jasmine.clock().tick 503 63 | expect(view).toBeHidden() 64 | jasmine.clock().uninstall() 65 | 66 | it "escape RegExp flag", -> 67 | $inputor = $('#inputor2').atwho 68 | at: "$" 69 | data: fixtures["names"] 70 | 71 | controller = $inputor.data('atwho').setContextFor("$").controller() 72 | simulateTypingIn $inputor, "$" 73 | expect(controller.view.visible()).toBe true 74 | 75 | it "can be trigger with no space", -> 76 | $inputor = $('#inputor3').atwho 77 | at: "@" 78 | data: fixtures["names"] 79 | startWithSpace: no 80 | 81 | controller = $inputor.data('atwho').setContextFor("@").controller() 82 | simulateTypingIn $inputor 83 | expect(controller.view.visible()).toBe true 84 | 85 | it 'highlight first', -> 86 | simulateTypingIn $inputor 87 | expect(controller.view.$el.find('ul li:first')).toHaveClass('cur') 88 | $inputor.atwho 89 | at: '@' 90 | highlightFirst: false 91 | simulateTypingIn $inputor 92 | expect(controller.view.$el.find('ul li:first')).not.toHaveClass('cur') 93 | 94 | it 'query out of maxLen', -> 95 | $inputor.atwho 96 | at: '@' 97 | maxLen: 0 98 | simulateTypingIn $inputor 99 | expect(controller.query).toBe null 100 | 101 | it 'should not build query or run afterMatchFailed callback when out of minLen', -> 102 | $inputor = $('#editable').atwho 103 | at: '@' 104 | minLen: 2 105 | callbacks: 106 | afterMatchFailed: (at, $el) -> 107 | $el.replaceWith('
    ') 108 | 109 | simulateTypingIn $inputor 110 | expect(controller.query).toBe null 111 | expect($('#failed-match').length).toBe 0 112 | 113 | describe "`data` as url and load remote data", -> 114 | 115 | beforeEach -> 116 | jasmine.Ajax.install() 117 | controller = app.controller() 118 | controller.model.save null 119 | $inputor.atwho 120 | at: "@" 121 | data: "/atwho.json" 122 | 123 | afterEach -> 124 | jasmine.Ajax.uninstall() 125 | 126 | it "data should be empty at first", -> 127 | expect(controller.model.fetch().length).toBe 0 128 | 129 | it "should load data after focus inputor", -> 130 | simulateTypingIn $inputor 131 | 132 | request = jasmine.Ajax.requests.mostRecent() 133 | response_data = [{"name":"Jacob"}, {"name":"Joshua"}, {"name":"Jayden"}] 134 | request.respondWith 135 | status: 200 136 | responseText: JSON.stringify(response_data) 137 | 138 | expect(controller.model.fetch().length).toBe 3 139 | -------------------------------------------------------------------------------- /src/view.coffee: -------------------------------------------------------------------------------- 1 | # View class to control how At.js's view showing. 2 | # All classes share the same DOM view. 3 | class View 4 | 5 | # @param controller [Object] The Controller. 6 | constructor: (@context) -> 7 | @$el = $("
    ") 8 | @$elUl = @$el.children(); 9 | @timeoutID = null 10 | # create HTML DOM of list view if it does not exist 11 | @context.$el.append(@$el) 12 | this.bindEvent() 13 | 14 | init: -> 15 | id = @context.getOpt("alias") || @context.at.charCodeAt(0) 16 | header_tpl = this.context.getOpt("headerTpl") 17 | if (header_tpl && this.$el.children().length == 1) 18 | this.$el.prepend(header_tpl) 19 | @$el.attr('id': "at-view-#{id}") 20 | 21 | destroy: -> 22 | @$el.remove() 23 | 24 | bindEvent: -> 25 | $menu = @$el.find('ul') 26 | lastCoordX = 0 27 | lastCoordY = 0 28 | $menu.on 'mousemove.atwho-view','li', (e) => 29 | # If the mouse hasn't actually moved then exit. 30 | return if lastCoordX == e.clientX and lastCoordY == e.clientY 31 | lastCoordX = e.clientX 32 | lastCoordY = e.clientY 33 | $cur = $(e.currentTarget) 34 | return if $cur.hasClass('cur') 35 | $menu.find('.cur').removeClass 'cur' 36 | $cur.addClass 'cur' 37 | .on 'click.atwho-view', 'li', (e) => 38 | $menu.find('.cur').removeClass 'cur' 39 | $(e.currentTarget).addClass 'cur' 40 | this.choose(e) 41 | e.preventDefault() 42 | 43 | # Check if view is visible 44 | # 45 | # @return [Boolean] 46 | visible: -> 47 | $.expr.filters.visible(@$el[0]) 48 | 49 | highlighted: -> 50 | @$el.find(".cur").length > 0 51 | 52 | choose: (e) -> 53 | if ($li = @$el.find ".cur").length 54 | content = @context.insertContentFor $li 55 | 56 | @context._stopDelayedCall() 57 | @context.insert @context.callbacks("beforeInsert").call(@context, content, $li, e), $li 58 | @context.trigger "inserted", [$li, e] 59 | this.hide(e) 60 | @stopShowing = yes if @context.getOpt("hideWithoutSuffix") 61 | 62 | reposition: (rect) -> 63 | _window = if @context.app.iframeAsRoot then @context.app.window else window 64 | if rect.bottom + @$el.height() - $(_window).scrollTop() > $(_window).height() 65 | rect.bottom = rect.top - @$el.height() 66 | if rect.left > overflowOffset = $(_window).width() - @$el.width() - 5 67 | rect.left = overflowOffset 68 | offset = {left:rect.left, top:rect.bottom} 69 | @context.callbacks("beforeReposition")?.call(@context, offset) 70 | @$el.offset offset 71 | @context.trigger "reposition", [offset] 72 | 73 | next: -> 74 | cur = @$el.find('.cur').removeClass('cur') 75 | next = cur.next() 76 | next = @$el.find('li:first') if not next.length 77 | next.addClass 'cur' 78 | nextEl = next[0] 79 | offset = nextEl.offsetTop + nextEl.offsetHeight + (if nextEl.nextSibling then nextEl.nextSibling.offsetHeight else 0) 80 | @scrollTop Math.max(0, offset - this.$el.height()) 81 | 82 | prev: -> 83 | cur = @$el.find('.cur').removeClass('cur') 84 | prev = cur.prev() 85 | prev = @$el.find('li:last') if not prev.length 86 | prev.addClass 'cur' 87 | prevEl = prev[0] 88 | offset = prevEl.offsetTop + prevEl.offsetHeight + (if prevEl.nextSibling then prevEl.nextSibling.offsetHeight else 0) 89 | @scrollTop Math.max(0, offset - this.$el.height()) 90 | 91 | scrollTop: (scrollTop) -> 92 | scrollDuration = @context.getOpt('scrollDuration') 93 | if scrollDuration 94 | @$elUl.animate {scrollTop: scrollTop}, scrollDuration 95 | else 96 | @$elUl.scrollTop(scrollTop) 97 | 98 | show: -> 99 | if @stopShowing 100 | @stopShowing = false 101 | return 102 | if not this.visible() 103 | @$el.show() 104 | @$el.scrollTop 0 105 | @context.trigger 'shown' 106 | this.reposition(rect) if rect = @context.rect() 107 | 108 | hide: (e, time) -> 109 | return if not this.visible() 110 | if isNaN(time) 111 | @$el.hide() 112 | @context.trigger 'hidden', [e] 113 | else 114 | callback = => this.hide() 115 | clearTimeout @timeoutID 116 | @timeoutID = setTimeout callback, time 117 | 118 | # render list view 119 | render: (list) -> 120 | if not ($.isArray(list) and list.length > 0) 121 | this.hide() 122 | return 123 | 124 | @$el.find('ul').empty() 125 | $ul = @$el.find('ul') 126 | tpl = @context.getOpt('displayTpl') 127 | 128 | for item in list 129 | item = $.extend {}, item, {'atwho-at': @context.at} 130 | li = @context.callbacks("tplEval").call(@context, tpl, item, "onDisplay") 131 | $li = $ @context.callbacks("highlighter").call(@context, li, @context.query.text) 132 | $li.data("item-data", item) 133 | $ul.append $li 134 | 135 | this.show() 136 | $ul.find("li:first").addClass "cur" if @context.getOpt('highlightFirst') 137 | -------------------------------------------------------------------------------- /src/controller.coffee: -------------------------------------------------------------------------------- 1 | class Controller 2 | uid: -> 3 | (Math.random().toString(16)+"000000000").substr(2,8) + (new Date().getTime()) 4 | 5 | constructor: (@app, @at) -> 6 | @$inputor = @app.$inputor 7 | @id = @$inputor[0].id || this.uid() 8 | @expectedQueryCBId = null 9 | 10 | @setting = null 11 | @query = null 12 | @pos = 0 13 | @range = null 14 | if (@$el = $("#atwho-ground-#{@id}", @app.$el)).length == 0 15 | @app.$el.append @$el = $("
    ") 16 | 17 | @model = new Model(this) 18 | @view = new View(this) 19 | 20 | init: (setting) -> 21 | @setting = $.extend {}, @setting || $.fn.atwho.default, setting 22 | @view.init() 23 | @model.reload @setting.data 24 | 25 | destroy: -> 26 | this.trigger 'beforeDestroy' 27 | @model.destroy() 28 | @view.destroy() 29 | @$el.remove() 30 | 31 | callDefault: (funcName, args...) -> 32 | try 33 | DEFAULT_CALLBACKS[funcName].apply this, args 34 | catch error 35 | $.error "#{error} Or maybe At.js doesn't have function #{funcName}" 36 | 37 | # Delegate custom `jQueryEvent` to the inputor 38 | # This function will add `atwho` as namespace to every jQuery event 39 | # and pass current context as the last param to it. 40 | # 41 | # @example 42 | # this.trigger "roll_n_rock", [1,2,3,4] 43 | # 44 | # $inputor.on "rool_n_rock", (e, one, two, three, four) -> 45 | # console.log one, two, three, four 46 | # 47 | # @param name [String] Event name 48 | # @param data [Array] data to callback 49 | trigger: (name, data=[]) -> 50 | data.push this 51 | alias = this.getOpt('alias') 52 | eventName = if alias then "#{name}-#{alias}.atwho" else "#{name}.atwho" 53 | @$inputor.trigger eventName, data 54 | 55 | # Get callback either in settings which was set by plugin user or in default callbacks list. 56 | # 57 | # @param funcName [String] callback's name 58 | # @return [Function] The callback. 59 | callbacks: (funcName)-> 60 | this.getOpt("callbacks")[funcName] || DEFAULT_CALLBACKS[funcName] 61 | 62 | # Because different registered at chars have different settings. 63 | # so we should give their own for them. 64 | # 65 | # @param at [String] setting's at name 66 | # @param default_value [?] return this if nothing is returned from current settings. 67 | # @return [?] setting's value 68 | getOpt: (at, default_value) -> 69 | try 70 | @setting[at] 71 | catch e 72 | null 73 | 74 | insertContentFor: ($li) -> 75 | tpl = this.getOpt('insertTpl') 76 | data = $.extend {}, $li.data('item-data'), {'atwho-at': @at} 77 | this.callbacks("tplEval").call(this, tpl, data, "onInsert") 78 | 79 | # Render list view 80 | # 81 | # @param data [Array] The data 82 | renderView: (data) -> 83 | searchKey = this.getOpt("searchKey") 84 | data = this.callbacks("sorter").call(this, @query.text, data[0..1000] , searchKey) 85 | @view.render data[0...this.getOpt('limit')] 86 | 87 | @arrayToDefaultHash: (data) -> 88 | return data if not $.isArray data 89 | for item in data 90 | if $.isPlainObject item then item else name:item 91 | 92 | # Searching! 93 | lookUp: (e) -> 94 | return if e && e.type == 'click' && !@getOpt('lookUpOnClick') 95 | return if @getOpt('suspendOnComposing') and @app.isComposing 96 | query = @catchQuery e 97 | if not query 98 | @expectedQueryCBId = null 99 | return query 100 | @app.setContextFor @at 101 | if wait = this.getOpt('delay') 102 | @_delayLookUp query, wait 103 | else 104 | @_lookUp query 105 | query 106 | 107 | _delayLookUp: (query, wait) -> 108 | now = if Date.now then Date.now() else new Date().getTime() 109 | @previousCallTime ||= now 110 | remaining = wait - (now - @previousCallTime) 111 | if 0 < remaining < wait 112 | @previousCallTime = now 113 | @_stopDelayedCall() 114 | @delayedCallTimeout = setTimeout(=> 115 | @previousCallTime = 0 116 | @delayedCallTimeout = null 117 | @_lookUp query 118 | , wait) 119 | else 120 | @_stopDelayedCall() 121 | @previousCallTime = 0 if @previousCallTime isnt now 122 | @_lookUp query 123 | 124 | _stopDelayedCall: -> 125 | if @delayedCallTimeout 126 | clearTimeout @delayedCallTimeout 127 | @delayedCallTimeout = null 128 | 129 | _generateQueryCBId: -> 130 | return {}; 131 | 132 | _lookUp: (query) -> 133 | _callback = (queryCBId, data) -> 134 | # ensure only the latest instance of this function perform actions 135 | if queryCBId isnt @expectedQueryCBId 136 | return 137 | if data and data.length > 0 138 | this.renderView @constructor.arrayToDefaultHash data 139 | else 140 | @view.hide() 141 | @expectedQueryCBId = @_generateQueryCBId() 142 | @model.query query.text, $.proxy(_callback, this, @expectedQueryCBId) 143 | -------------------------------------------------------------------------------- /spec/javascripts/default_callbacks.spec.coffee: -------------------------------------------------------------------------------- 1 | 2 | $inputor = null 3 | app = null 4 | 5 | describe "default callbacks", -> 6 | $ = jQuery 7 | callbacks = null 8 | text = null 9 | 10 | beforeEach -> 11 | loadFixtures("inputors.html") 12 | $inputor = $("#inputor").atwho at: "@", data: fixtures["names"] 13 | app = getAppOf $inputor 14 | 15 | beforeEach -> 16 | text = $.trim $inputor.text() 17 | callbacks = $.fn.atwho.default.callbacks 18 | app = $inputor.data("atwho") 19 | 20 | afterEach -> 21 | $inputor.atwho 'destroy' 22 | 23 | it "refactor the data before save", -> 24 | items = callbacks.beforeSave.call(app.controller(), fixtures["names"]) 25 | expect(items).toContain({"name":"Jacob"}) 26 | expect(items).toContain({"name":"Isabella"}) 27 | 28 | it "should match the key word following @", -> 29 | query = callbacks.matcher.call(app, "@", text) 30 | expect(query).toBe("Jobs") 31 | 32 | it "should not match a space following @ if acceptSpaceBar flag omitted", -> 33 | $inputor = $("#inputor").atwho at: "@", data: fixtures["names"] 34 | text = $.trim $inputor.text() 35 | query = callbacks.matcher.call(app, "@", text) 36 | expect(query).toBe("Jobs") 37 | 38 | it "should not match a space following @ if acceptSpaceBar flag false", -> 39 | $inputor = $("#inputor").atwho at: "@", data: fixtures["names"], acceptSpaceBar: false 40 | text = $.trim $inputor.text() 41 | query = callbacks.matcher.call(app, "@", text, false, false) 42 | expect(query).toBe("Jobs") 43 | 44 | it "should match a space following @ if acceptSpaceBar flag set to true", -> 45 | $inputor = $("#inputor4").atwho at: "@", data: fixtures["names"], acceptSpaceBar: true 46 | text = $.trim $inputor.text() 47 | query = callbacks.matcher.call(app, "@", text, false, true) 48 | expect(query).toBe("Jobs Blobs") 49 | 50 | it "should match the key word fllowing @ with specials chars", -> 51 | $inputor = $("#special-chars").atwho at: "@", data: fixtures["names"] 52 | text = $.trim $inputor.text() 53 | 54 | query = callbacks.matcher.call(app, "@", text) 55 | expect(query).toBe(decodeURI("J%C3%A9r%C3%A9m%C3%BF")) 56 | 57 | it "can filter data", -> 58 | names = callbacks.beforeSave.call(app.controller(), fixtures["names"]) 59 | names = callbacks.filter.call(app, "jo", names, "name") 60 | expect(names).toContain name: "Joshua" 61 | 62 | it "can filter numeric data", -> 63 | numerics = callbacks.beforeSave.call(app.controller(), fixtures["numerics"]) 64 | numerics = callbacks.filter.call(app, "1", numerics, "name") 65 | expect(numerics).toContain name: 10 66 | 67 | it "request data from remote by ajax if set remoteFilter", -> 68 | remote_call = jasmine.createSpy("remote_call") 69 | $inputor.atwho 70 | at: "@" 71 | data: null, 72 | callbacks: 73 | remoteFilter: remote_call 74 | 75 | simulateTypingIn $inputor 76 | expect(remote_call).toHaveBeenCalled() 77 | 78 | it "can sort the data", -> 79 | names = callbacks.beforeSave.call(app.controller(), fixtures["names"]) 80 | names = callbacks.sorter.call(app, "e", names, "name") 81 | expect(names[0].name).toBe 'Ethan' 82 | 83 | it "can sort numeric data", -> 84 | numerics = callbacks.beforeSave.call(app.controller(), fixtures["numerics"]) 85 | numerics = callbacks.sorter.call(app, "1", numerics, "name") 86 | expect(numerics[0].name).toBe 13 87 | 88 | it "don't sort the data without a query", -> 89 | names = callbacks.beforeSave.call(app.controller(), fixtures["names"]) 90 | names = callbacks.sorter.call(app, "", names, "name") 91 | expect(names[0]).toEqual({ name : 'Jacob' }) 92 | 93 | it "can eval temple", -> 94 | map = {name: "username", nick: "nick_name"} 95 | tpl = '
  • ${nick}
  • ' 96 | html = '
  • nick_name
  • ' 97 | 98 | result = callbacks.tplEval.call(app, tpl, map) 99 | expect(result).toBe(html) 100 | 101 | it "can evaluate template as a function", -> 102 | map = {name: "username", nick: "nick_name"} 103 | tpl = (map)-> '
  • '+map.nick+'
  • ' 104 | html = '
  • nick_name
  • ' 105 | result = callbacks.tplEval.call(app, tpl, map) 106 | expect(result).toBe(html) 107 | 108 | 109 | it "can highlight the query", -> 110 | html = '
  • Ethan
  • ' 111 | highlighted = callbacks.highlighter.call(app, html, "e") 112 | result = '
  • Ethan
  • ' 113 | expect(highlighted).toBe(result) 114 | 115 | it "can insert the text which be choosed", -> 116 | spyOn(callbacks, "beforeInsert").and.callThrough() 117 | triggerAtwhoAt $inputor 118 | expect(callbacks.beforeInsert).toHaveBeenCalled() 119 | 120 | it "can adjust offset before reposition", -> 121 | spyOn(callbacks, "beforeReposition").and.callThrough() 122 | triggerAtwhoAt $inputor 123 | expect(callbacks.beforeReposition).toHaveBeenCalled() 124 | -------------------------------------------------------------------------------- /src/default.coffee: -------------------------------------------------------------------------------- 1 | KEY_CODE = 2 | ESC: 27 3 | TAB: 9 4 | ENTER: 13 5 | CTRL: 17 6 | A: 65 7 | P: 80 8 | N: 78 9 | LEFT: 37 10 | UP:38 11 | RIGHT: 39 12 | DOWN: 40 13 | BACKSPACE: 8 14 | SPACE: 32 15 | 16 | # Functions set for handling and rendering the data. 17 | # Others developers can override these methods to tweak At.js such as matcher. 18 | # We can override them in `callbacks` settings. 19 | # 20 | # @mixin 21 | # 22 | # The context of these functions is `$.atwho.Controller` object and they are called in this sequences: 23 | # 24 | # [beforeSave, matcher, filter, remoteFilter, sorter, tplEvl, highlighter, beforeInsert, afterMatchFailed] 25 | # 26 | DEFAULT_CALLBACKS = 27 | 28 | # It would be called to restructure the data before At.js invokes `Model#save` to save data 29 | # By default, At.js will convert it to a Hash Array. 30 | # 31 | # @param data [Array] data to refacotor. 32 | # @return [Array] Data after refactor. 33 | beforeSave: (data) -> 34 | Controller.arrayToDefaultHash data 35 | 36 | # It would be called to match the `flag`. 37 | # It will match at start of line or after whitespace 38 | # 39 | # @param flag [String] current `flag` ("@", etc) 40 | # @param subtext [String] Text from start to current caret position. 41 | # @param should_startWithSpace [boolean] accept white space as beginning of match. 42 | # @param acceptSpaceBar [boolean] accept a space bar in the center of match, 43 | # so you can match a first and last name, for ex. 44 | # 45 | # @return [String | null] Matched result. 46 | matcher: (flag, subtext, should_startWithSpace, acceptSpaceBar) -> 47 | # escape RegExp 48 | flag = flag.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&") 49 | flag = '(?:^|\\s)' + flag if should_startWithSpace 50 | 51 | # À 52 | _a = decodeURI("%C3%80") 53 | # ÿ 54 | _y = decodeURI("%C3%BF") 55 | space = if acceptSpaceBar then "\ " else "" 56 | regexp = new RegExp "#{flag}([A-Za-z#{_a}-#{_y}0-9_#{space}\'\.\+\-]*)$|#{flag}([^\\x00-\\xff]*)$",'gi' 57 | match = regexp.exec subtext 58 | if match then match[2] || match[1] else null 59 | 60 | # --------------------- 61 | 62 | # Filter data by matched string. 63 | # 64 | # @param query [String] Matched string. 65 | # @param data [Array] data list 66 | # @param searchKey [String] at char for searching. 67 | # 68 | # @return [Array] result data. 69 | filter: (query, data, searchKey) -> 70 | # !!null #=> false; !!undefined #=> false; !!'' #=> false; 71 | _results = [] 72 | for item in data 73 | _results.push item if ~new String(item[searchKey]).toLowerCase().indexOf query.toLowerCase() 74 | _results 75 | 76 | # If a function is given, At.js will invoke it if local filter can not find any data 77 | # 78 | # @param params [String] matched query 79 | # @param callback [Function] callback to render page. 80 | remoteFilter: null 81 | # remoteFilter: (query, callback) -> 82 | # $.ajax url, 83 | # data: params 84 | # success: (data) -> 85 | # callback(data) 86 | 87 | # Sorter data of course. 88 | # 89 | # @param query [String] matched string 90 | # @param items [Array] data that was refactored 91 | # @param searchKey [String] at char to search 92 | # 93 | # @return [Array] sorted data 94 | sorter: (query, items, searchKey) -> 95 | return items unless query 96 | 97 | _results = [] 98 | for item in items 99 | item.atwho_order = new String(item[searchKey]).toLowerCase().indexOf query.toLowerCase() 100 | _results.push item if item.atwho_order > -1 101 | 102 | _results.sort (a,b) -> a.atwho_order - b.atwho_order 103 | 104 | # Evaluate the template either as a string or as a function 105 | # this allows someone to pass in a set of data that needs a 106 | # different template for different data results 107 | # 108 | # @param tpl [function] the template function or string 109 | # @param map [Hash] Data map to eval. 110 | tplEval: (tpl, map) -> 111 | template = tpl 112 | try 113 | template = tpl(map) unless typeof tpl == 'string' 114 | template.replace /\$\{([^\}]*)\}/g, (tag, key, pos) -> map[key] 115 | catch error 116 | "" 117 | 118 | 119 | # Highlight the `matched query` string. 120 | # 121 | # @param li [String] HTML String after eval. 122 | # @param query [String] matched query. 123 | # 124 | # @return [String] highlighted string. 125 | highlighter: (li, query) -> 126 | return li if not query 127 | regexp = new RegExp(">\\s*([^\<]*?)(" + query.replace("+","\\+") + ")([^\<]*)\\s*<", 'ig') 128 | li.replace regexp, (str, $1, $2, $3) -> '> '+$1+'' + $2 + ''+$3+' <' 129 | 130 | # What to do before inserting item's value into inputor. 131 | # 132 | # @param value [String] content to insert 133 | # @param $li [jQuery Object] the chosen item 134 | # @param e [event Object] from the user selection (keyDown or click) 135 | beforeInsert: (value, $li, e) -> 136 | value 137 | 138 | # You can adjust the menu's offset here. 139 | # 140 | # @param offset [Hash] offset will be applied to menu 141 | # beforeReposition: (offset) -> 142 | # offset.left += 10 143 | # offset.top += 10 144 | # offset 145 | beforeReposition: (offset) -> offset 146 | 147 | afterMatchFailed: (at, el) -> 148 | -------------------------------------------------------------------------------- /src/app.coffee: -------------------------------------------------------------------------------- 1 | # At.js central contoller(searching, matching, evaluating and rendering.) 2 | class App 3 | 4 | # @param inputor [HTML DOM Object] `input` or `textarea` 5 | constructor: (inputor) -> 6 | @currentFlag = null 7 | @controllers = {} 8 | @aliasMaps = {} 9 | @$inputor = $(inputor) 10 | this.setupRootElement() 11 | this.listen() 12 | 13 | createContainer: (doc) -> 14 | @$el?.remove() 15 | $ doc.body 16 | .append @$el = $ "
    " 17 | 18 | setupRootElement: (iframe, asRoot=false) -> 19 | if iframe 20 | @window = iframe.contentWindow 21 | @document = iframe.contentDocument || @window.document 22 | @iframe = iframe 23 | else 24 | @document = @$inputor[0].ownerDocument 25 | @window = @document.defaultView || @document.parentWindow 26 | try 27 | @iframe = @window.frameElement 28 | catch error 29 | @iframe = null 30 | if $.fn.atwho.debug 31 | throw new Error """ 32 | iframe auto-discovery is failed. 33 | Please use `setIframe` to set the target iframe manually. 34 | #{error} 35 | """ 36 | this.createContainer if @iframeAsRoot = asRoot then @document else document 37 | 38 | controller: (at) -> 39 | if @aliasMaps[at] 40 | current = @controllers[@aliasMaps[at]] 41 | else 42 | for currentFlag, c of @controllers 43 | if currentFlag is at 44 | current = c 45 | break 46 | 47 | if current then current else @controllers[@currentFlag] 48 | 49 | setContextFor: (at) -> 50 | @currentFlag = at 51 | this 52 | 53 | # At.js can register multiple at char (flag) to every inputor such as "@" and ":" 54 | # Along with their own `settings` so that it works differently. 55 | # After register, we still can update their `settings` such as updating `data` 56 | # 57 | # @param flag [String] at char (flag) 58 | # @param settings [Hash] the settings 59 | reg: (flag, setting) -> 60 | controller = @controllers[flag] ||= 61 | if @$inputor.is '[contentEditable]' 62 | new EditableController this, flag 63 | else 64 | new TextareaController this, flag 65 | # TODO: it will produce rubbish alias map, reduse this. 66 | @aliasMaps[setting.alias] = flag if setting.alias 67 | controller.init setting 68 | this 69 | 70 | # binding jQuery events of `inputor`'s 71 | listen: -> 72 | @$inputor 73 | .on 'compositionstart', (e) => 74 | this.controller()?.view.hide() 75 | @isComposing = true 76 | null 77 | .on 'compositionend', (e) => 78 | @isComposing = false 79 | setTimeout(() => @dispatch(e)) 80 | null 81 | .on 'keyup.atwhoInner', (e) => 82 | this.onKeyup(e) 83 | .on 'keydown.atwhoInner', (e) => 84 | this.onKeydown(e) 85 | .on 'blur.atwhoInner', (e) => 86 | if c = this.controller() 87 | c.expectedQueryCBId = null 88 | c.view.hide(e,c.getOpt("displayTimeout")) 89 | .on 'click.atwhoInner', (e) => 90 | this.dispatch e 91 | .on 'scroll.atwhoInner', do => 92 | # make returned handler handle the very first call properly 93 | lastScrollTop = @$inputor.scrollTop() 94 | (e) => 95 | currentScrollTop = e.target.scrollTop 96 | if lastScrollTop != currentScrollTop 97 | @controller()?.view.hide(e) 98 | lastScrollTop = currentScrollTop 99 | true # ensure we don't stop bubbling 100 | 101 | shutdown: -> 102 | for _, c of @controllers 103 | c.destroy() 104 | delete @controllers[_] 105 | @$inputor.off '.atwhoInner' 106 | @$el.remove() 107 | 108 | dispatch: (e) -> 109 | c.lookUp(e) for _, c of @controllers 110 | 111 | onKeyup: (e) -> 112 | switch e.keyCode 113 | when KEY_CODE.ESC 114 | e.preventDefault() 115 | this.controller()?.view.hide() 116 | when KEY_CODE.DOWN, KEY_CODE.UP, KEY_CODE.CTRL 117 | $.noop() 118 | when KEY_CODE.P, KEY_CODE.N 119 | this.dispatch e if not e.ctrlKey 120 | else 121 | this.dispatch e 122 | # coffeescript will return everywhere!! 123 | return 124 | 125 | onKeydown: (e) -> 126 | # return if not (view = this.controller().view).visible() 127 | view = this.controller()?.view 128 | return if not (view and view.visible()) 129 | switch e.keyCode 130 | when KEY_CODE.ESC 131 | e.preventDefault() 132 | view.hide(e) 133 | when KEY_CODE.UP 134 | e.preventDefault() 135 | view.prev() 136 | when KEY_CODE.DOWN 137 | e.preventDefault() 138 | view.next() 139 | when KEY_CODE.P 140 | return if not e.ctrlKey 141 | e.preventDefault() 142 | view.prev() 143 | when KEY_CODE.N 144 | return if not e.ctrlKey 145 | e.preventDefault() 146 | view.next() 147 | when KEY_CODE.TAB, KEY_CODE.ENTER, KEY_CODE.SPACE 148 | return if not view.visible() 149 | return if not this.controller().getOpt('spaceSelectsMatch') and e.keyCode == KEY_CODE.SPACE 150 | return if not this.controller().getOpt('tabSelectsMatch') and e.keyCode == KEY_CODE.TAB 151 | if view.highlighted() 152 | e.preventDefault() 153 | view.choose(e) 154 | else 155 | view.hide(e) 156 | else 157 | $.noop() 158 | return 159 | -------------------------------------------------------------------------------- /src/editableController.coffee: -------------------------------------------------------------------------------- 1 | class EditableController extends Controller 2 | 3 | _getRange: -> 4 | sel = @app.window.getSelection() 5 | sel.getRangeAt(0) if sel.rangeCount > 0 6 | 7 | _setRange: (position, node, range=@_getRange()) -> 8 | return unless range and node 9 | node = $(node)[0] 10 | if position == 'after' 11 | range.setEndAfter node 12 | range.setStartAfter node 13 | else 14 | range.setEndBefore node 15 | range.setStartBefore node 16 | range.collapse false 17 | @_clearRange range 18 | 19 | _clearRange: (range=@_getRange()) -> 20 | sel = @app.window.getSelection() 21 | #ctrl+a remove defaults using the flag 22 | if !@ctrl_a_pressed? 23 | sel.removeAllRanges() 24 | sel.addRange range 25 | 26 | _movingEvent: (e) -> 27 | e.type == 'click' or e.which in [KEY_CODE.RIGHT, KEY_CODE.LEFT, KEY_CODE.UP, KEY_CODE.DOWN] 28 | 29 | _unwrap: (node) -> 30 | node = $(node).unwrap().get 0 31 | if (next = node.nextSibling) and next.nodeValue 32 | node.nodeValue += next.nodeValue 33 | $(next).remove() 34 | node 35 | 36 | catchQuery: (e) -> 37 | return unless range = @_getRange() 38 | return unless range.collapsed 39 | return unless e 40 | 41 | if e.which == KEY_CODE.ENTER 42 | ($query = $(range.startContainer).closest '.atwho-query') 43 | .contents().unwrap() 44 | $query.remove() if $query.is ':empty' 45 | ($query = $ ".atwho-query", @app.document) 46 | .text $query.text() 47 | .contents().last().unwrap() 48 | @_clearRange() 49 | return 50 | 51 | # absorb range 52 | # The range at the end of an element is not inside in firefox but not others browsers including IE. 53 | # To normolize them, we have to move the range inside the element while deleting content or moving caret right after .atwho-inserted 54 | if /firefox/i.test(navigator.userAgent) 55 | if $(range.startContainer).is @$inputor 56 | @_clearRange() 57 | return 58 | if e.which == KEY_CODE.BACKSPACE and range.startContainer.nodeType == document.ELEMENT_NODE \ 59 | and (offset = range.startOffset - 1) >= 0 60 | _range = range.cloneRange() 61 | _range.setStart range.startContainer, offset 62 | if $(_range.cloneContents()).contents().last().is '.atwho-inserted' 63 | inserted = $(range.startContainer).contents().get(offset) 64 | @_setRange 'after', $(inserted).contents().last() 65 | else if e.which == KEY_CODE.LEFT and range.startContainer.nodeType == document.TEXT_NODE 66 | $inserted = $ range.startContainer.previousSibling 67 | if $inserted.is('.atwho-inserted') and range.startOffset == 0 68 | @_setRange 'after', $inserted.contents().last() 69 | 70 | # modifying inserted element 71 | $(range.startContainer) 72 | .closest '.atwho-inserted' 73 | .addClass 'atwho-query' 74 | .siblings().removeClass 'atwho-query' 75 | 76 | if ($query = $ ".atwho-query", @app.document).length > 0 \ 77 | and $query.is(':empty') and $query.text().length == 0 78 | $query.remove() 79 | 80 | if not @_movingEvent e 81 | $query.removeClass 'atwho-inserted' 82 | 83 | if $query.length > 0 84 | switch e.which 85 | when KEY_CODE.LEFT 86 | @_setRange 'before', $query.get(0), range 87 | $query.removeClass 'atwho-query' 88 | return 89 | when KEY_CODE.RIGHT 90 | @_setRange 'after', $query.get(0).nextSibling, range 91 | $query.removeClass 'atwho-query' 92 | return 93 | 94 | # matching 95 | if $query.length > 0 and query_content = $query.attr('data-atwho-at-query') 96 | $query.empty().html(query_content).attr('data-atwho-at-query', null) 97 | @_setRange 'after', $query.get(0), range 98 | _range = range.cloneRange() 99 | _range.setStart range.startContainer, 0 100 | matched = @callbacks("matcher").call(this, @at, _range.toString(), @getOpt('startWithSpace'), @getOpt("acceptSpaceBar")) 101 | isString = typeof matched is 'string' 102 | 103 | # wrapping query with .atwho-query 104 | if $query.length == 0 and isString \ 105 | and (index = range.startOffset - @at.length - matched.length) >= 0 106 | range.setStart range.startContainer, index 107 | $query = $ '', @app.document 108 | .attr @getOpt "editableAtwhoQueryAttrs" 109 | .addClass 'atwho-query' 110 | range.surroundContents $query.get 0 111 | lastNode = $query.contents().last().get(0) 112 | if lastNode 113 | if /firefox/i.test navigator.userAgent 114 | range.setStart lastNode, lastNode.length 115 | range.setEnd lastNode, lastNode.length 116 | @_clearRange range 117 | else 118 | @_setRange 'after', lastNode, range 119 | 120 | return if isString and matched.length < @getOpt('minLen', 0) 121 | 122 | # handle the matched result 123 | if isString and matched.length <= @getOpt('maxLen', 20) 124 | query = text: matched, el: $query 125 | @trigger "matched", [@at, query.text] 126 | @query = query 127 | else 128 | @view.hide() 129 | @query = el: $query 130 | if $query.text().indexOf(this.at) >= 0 131 | if @_movingEvent(e) and $query.hasClass 'atwho-inserted' 132 | $query.removeClass('atwho-query') 133 | else if false != @callbacks('afterMatchFailed').call this, @at, $query 134 | @_setRange "after", @_unwrap $query.text($query.text()).contents().first() 135 | null 136 | 137 | # Get offset of current at char(`flag`) 138 | # 139 | # @return [Hash] the offset which look likes this: {top: y, left: x, bottom: bottom} 140 | rect: -> 141 | rect = @query.el.offset() 142 | # do not use {top: 0, left: 0} from jQuery when element is hidden 143 | # happens every other time the menu is displayed on click in contenteditable 144 | return unless rect and @query.el[0].getClientRects().length 145 | if @app.iframe and not @app.iframeAsRoot 146 | iframeOffset = ($iframe = $ @app.iframe).offset() 147 | rect.left += iframeOffset.left - @$inputor.scrollLeft() 148 | rect.top += iframeOffset.top - @$inputor.scrollTop() 149 | rect.bottom = rect.top + @query.el.height() 150 | rect 151 | 152 | # Insert value of `data-value` attribute of chosen item into inputor 153 | # 154 | # @param content [String] string to insert 155 | insert: (content, $li) -> 156 | @$inputor.focus() unless @$inputor.is ':focus' 157 | overrides = @getOpt 'functionOverrides' 158 | if overrides.insert 159 | return overrides.insert.call this, content, $li 160 | suffix = if (suffix = @getOpt 'suffix') == "" then suffix else suffix or "\u00A0" 161 | data = $li.data('item-data') 162 | @query.el 163 | .removeClass 'atwho-query' 164 | .addClass 'atwho-inserted' 165 | .html content 166 | .attr 'data-atwho-at-query', "" + data['atwho-at'] + @query.text 167 | if range = @_getRange() 168 | if @query.el.length 169 | range.setEndAfter @query.el[0] 170 | range.collapse false 171 | range.insertNode suffixNode = @app.document.createTextNode "\u200B" + suffix 172 | @_setRange 'after', suffixNode, range 173 | @$inputor.focus() unless @$inputor.is ':focus' 174 | @$inputor.change() 175 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | At.js 6 | 7 | 8 | 9 | 10 | 11 | 12 | 80 | 81 | 82 | 83 | 165 | 166 | 167 | 168 |
    169 | 170 | 171 |
    172 |

    At.js

    173 |
    174 |
    175 |
    176 | 177 |
    178 | 179 |
    180 |

    181 | And!! it support ContentEditable mode too!! 182 | 183 | 184 | 185 |

    186 |

    187 | Try here now! 188 | :h 189 |

    190 |
    191 | 192 |
    193 | 194 |
    195 | 196 | 201 | 202 |
    203 |
    204 | 205 | 206 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### v1.5.0 2 | 3 | add `headerTpl` settings 4 | 5 | * 7a41d93 - #375 from vcekov/fix_scroll_position - Valentin Cekov 6 | * ecbf34f - #373 from vcekov/val/fix_key_navigation_interefence_with_mouse - Valentin Cekov 7 | * b68cf84 - #364 from WorktileTech/master - Harold.Luo 8 | * f836f04 - #372 from vcekov/fix_caret_for_space_after_@ - Harold.Luo 9 | * 06cf6bb - Properly set caret position after failed match - Valentin Cekov 10 | * c9ed2e2 - support header template. - htz 11 | 12 | ### v1.4.0 13 | 14 | #### Contenteditable 15 | 16 | Pressing `Backspace` will turn the inserted element back to the origin query 'moment'. 17 | 18 | * 84edc9f - skip inserted element when moving left or right - ichord 19 | * 25a61d3 - the jQuery npm package is now called jquery. Fixes #338 - Mick Staugaard 20 | * 03ed71f - Merge pull request #351 from mociepka/master - Harold.Luo 21 | * ae00dc3 - Point main script in package json - Michał Ociepka 22 | * c5f31f5 - Merge branch 'dev' into HEAD - ichord 23 | * c399397 - fix contenteditable cursor bug when typing "a" into query - ichord 24 | * 7f4295a - fix previous replacements get clobbered when re-intering the inserted element - ichord 25 | * f00fabd - Merge pull request #354 from lvegerano/master - Harold.Luo 26 | * a42065e - Adds guard to event and dist file - Luis Vegerano 27 | * e4aaa30 - Add option to disable loopUp on click - Luis Vegerano 28 | * c9b7609 - Fix bug where callbacks would run before reaching minLen. Fixes #329. - Mike Leone 29 | * f8692dc - Add support for minLen. Connects to issue #316. - Mike Leone 30 | * fd7d298 - FIX: the value of `isSelecting` - ichord 31 | * c374c93 - FIX: IME typing error - ichord 32 | 33 | ### v1.3.0 34 | 35 | * 7f2189d - fix #294 inserts "" suffix in contenteditable 36 | * bae95d9 - add `tabSelectsMatch` setting to make tab selection optional 37 | * e966aba - Merge pull request #298 from kkirsche/patch-1 - Harold.Luo 38 | * 9f78239 - Remove moot `version` property from bower.json - Kevin Kirsche 39 | 40 | ### v1.2.0 41 | 42 | db09ac7 -> 886613f 43 | 44 | * 886613f - add `$.fn.atwho.debug = false` to trigger debug mode 45 | * 6567af9 - Enable default events when nothing is highlighted - Teemu 46 | * 752ad4a - Add scrollDuration option. - Takuru 47 | * bf17d43 - add parameter to allow for a spacebar in the middle of a search so that you can match a first + last name, for example - Feather Knee 48 | * a1d5fe7 - add `reposition` API - ichord 49 | * 9bcb06e - add "onInsert", "onDispaly" arguments to `tplEval` - ichord 50 | * db09ac7 - add `hide` api - ichord 51 | 52 | ### v1.1.0 53 | 54 | * lisafeather/displyTplCallBack - #259 55 | * ADD: `editableAtwhoQueryAttrs` options 56 | * Added setting for 'spaceSelectsMatch' (default false/off) 57 | 58 | ### v1.0.0 59 | 60 | **The naming convention are using camel case**. 61 | It means that every callback and setting's name are switched from underscope_naming to CamelNaming. 62 | Sorry about this. 63 | 64 | Future version's naming will follow the rules of http://semver.org constantly. 65 | 66 | #### Options: 67 | 68 | * Replaced `tpl` with `displayTpl`: display template of dropdown menu items. 69 | In previous versions, At.js will fetch the value of `data-value` to insert; It stops doing it. 70 | Please use the `insertTpl` option to manage the content to insert instead. 71 | The default value is `"
  • ${name}
  • "` 72 | * The `insertTpl` option will be used in *textarea* as well. 73 | The default value is `"${atwho-at}${name}"` 74 | 75 | #### Callbacks: 76 | 77 | * Added `afterMatchFailed` callback to *contentEditable* 78 | It will be invoked after fail to match any query and stopping matching. 79 | Open *examples/hashtas.html* to examine how it work. 80 | * Removed `inserting_wrapper` callback to *contentEditable* 81 | 82 | #### Internal changes: 83 | 84 | * refactor the `Controller` 85 | Introduced `EditableController` class to control actions of `contenteditable` element. 86 | Introduced `TextareaController` class to control actions of `textarea` element. 87 | Both of them are inherit from the `Controller` class. 88 | 89 | * Refactored contentEditable mode 90 | Inserted content are wrapped in a span: `` 91 | Querying content are wrapped in a span: `` 92 | 93 | * Bring back auto-discovery to iframe. 94 | * Fix wrong offset in iframe 95 | * Replaced `iframeStandalone` with `iframeAdRoot` 96 | * All processed events are preventing default and stopping propagation. 97 | 98 | ### v0.5.2 99 | 100 | * e1f6566 - fix error that doesn't display mention list on new line 101 | * 8fe3a54 - can insert multiple node from `inserting_wrapper` 102 | * 4080151 - scroll to top after showing 103 | * 01555f8 - scroll long dropdown list 104 | * 1b8999d - Add spm support 105 | * f2b8e9c - change name in package.json 106 | * b61bfdc - search on click 107 | * b1efd09 - Fixes error with selecting always first item on the list on iOS WebView when using https://github.com/ftlabs/fastclick 108 | * 7ed2890 - Allow accented characters in matcher 109 | 110 | ### v0.5.1 111 | 112 | * 219de3d - fix Goes off screen / gets cropped if there isn't enough room 113 | * 1100c5b - No longer inherits text colour from document 114 | * ce60958 - on more boolean argument for `setIframe` api to work cross-document issues #199 115 | 116 | ### v0.5.0 117 | 118 | * 593893c - refactor inserting of contenteditable 119 | Adding `inserting_wrapper` for customize wrapping inserting content. 120 | Not to insert item as a block in Firefox. check out issue #109. 121 | Removing `getInsertedItems`, `getInsertedIDs` API. You have to collect them on your own. 122 | * 4d3fb8f - have to set IFRAME manually 123 | * 1f13a16 - change space_after to suffix 124 | * b099ebb - fix caret position error after inserting 125 | * 2c47d7a - fix #178 hide view while clicking somewhere else 126 | 127 | ### v0.4.12 128 | 129 | * eeafab1 - fix error: will always call hidden atwho event 130 | * b0f6ceb - Highlighter finds the first occurrence 131 | * da256db - Adds possibility of having empty prefix (at keyword) in controllers 132 | * b884225 - add `space_after` option 133 | * 65d6273 - Passes esc/tab/return keyup events through to emitted hide event 134 | 135 | ### v0.4.11 136 | 137 | * bf938db - add `delay` setting, support delay searching 138 | * a0b5a6f - fix bug: terminate if query out of max_len 139 | * 01d6d5b - add css min file 140 | 141 | ### v0.4.10 142 | 143 | * update jquery dependence version 144 | 145 | ### v0.4.9 146 | 147 | * f317bd7 not lowercase query, add `highlight_first` option 148 | 149 | ### v0.4.8 150 | 151 | * 79bbef4 destroy atwho view container dom 152 | * 0372d65 update bower and component keywords 153 | * 52a41f5 add optional `before_repostion` callback 154 | * cc1c239 Fixes #143 - ichord 155 | 156 | ### v0.4.7 157 | 158 | * resolved #133, #135, #137. 159 | * add `beforeDestroy` event 160 | * wouldn't concat `caret.js` into `dist/js/jquery.atwho.js` any more. 161 | * seperate `jquery.atwho.coffee` into pieces. 162 | * seperate testing. 163 | 164 | ### v0.4.6 165 | 166 | * 2d9ab23 fix `wrong document` error in IE iframe 167 | 168 | ### v0.4.5 169 | 170 | * 664a765 support iframe 171 | 172 | ### v0.4.4 173 | 174 | * 9ac7e75 - improve contentEditable for IE 8 175 | 176 | It's still some bugs in IE 8, just DON'T use it 177 | I don't want to spend more time on IE 8. 178 | So it would be the ending fixup. And i will still leave related code for 179 | a while maybe in case anyone want to help to improve it. 180 | Just encourge your users to upgrate the browers or just switch to a 181 | batter one please !! 182 | 183 | * a8371b3 - move project page to master from gh-pages. 184 | * 24b6225 - fix bugs #122 185 | * 645e030 - update Caret.js to v0.0.5 186 | 187 | ### v0.4.3 188 | 189 | * e8e7561 update `Caret.js` to `v0.0.4` 190 | 191 | ### v0.4.2 192 | 193 | * 4169b74 - binding data storage to the inputor. issues #121 194 | * 11d053f - reduse querying twice. issues#112 195 | 196 | ### v0.4.1 197 | 198 | * b7721be - fix bug at view id was not been assign. close issues #99 199 | * 407f069 - fix bug: Can not autofocus after click the at-list in FireFox. #95 200 | * 917f033 - fix bug: click do not work in div-contenteditable. close issues #93 201 | 202 | ### v0.4.0 203 | 204 | * update `Caret.js` to `v0.0.2` 205 | * `contenteditable` support !! 206 | * change content of default item template `tpl` 207 | * new rule to insert the `at` : will always remove the `at` from inputor but will add it back from `tpl` in default. 208 | so, if you are using your own `tpl` and want to show the `at` char, you have to do it yourself. 209 | * add `insert_tpl` setting for `contenteditable`. 210 | it will insert `data-value` of li element that eval from `tpl` in default. 211 | * new APIs for `contenteditable`: `getInsertedItemsWithIDs`, `getInsertedItems`, `getInsertedIDs` 212 | 213 | ### 2013-08-07 - v0.3.2 214 | 215 | * bower 216 | * remove `Caret.js` codes and add it as bower dependencies 217 | * remove `display_flag` settings. 218 | * add `start_with_space` settings, default `true` 219 | * change `super_call` function to `call_default` 220 | 221 | ### 2013-04-28 222 | 223 | * release new api `load`, `run` 224 | * add `alias` setting for `load` data or as the view's id 225 | * matching key with a space before it 226 | * register key in settings `{at: "@", data: []}` instead of being a argument 227 | * `max_len` setting for max length to search 228 | * change the default matcher regrex rule: occur at start of line or after whitespace 229 | * will not sort the datay without valid query string 230 | 231 | ### 2013-04-23 232 | 233 | * group all data handlers as `Model` class. 234 | * All callbacks's context would be current `Controller` 235 | 236 | ### 2013-04-05 237 | 238 | * `data` setting will be used to load data either local or remote. If it's String as URL it will preload data from remote by launch a ajax request (every times At.js call `reg` to update settings) 239 | 240 | * remove default `remote_filter` from callbacks list. 241 | * add `get_data` and `save_data` function to contoller. They are used to get and save whole data for At.js 242 | * `save_data` will invoke `data_refactor` everytime 243 | 244 | * will filter local data which is set in `settings` first and if it get nothing then call `remote_filter` if it's exists in callbacks list that is set by user. 245 | 246 | ### 2013-04 247 | 248 | * remove ability of changing common setting after inputor binded 249 | * can fix list view after matched query in IE now. 250 | * separated core function (get offset of inputor) as a jquery plugins. 251 | 252 | ### v0.2.0 - 2012-12 253 | 254 | **No more testing in IEs browsers.** 255 | 256 | #### Note 257 | The name `atWho` was changed to `atwho`. 258 | 259 | #### New features 260 | 261 | * Customer data handlers(matcher, filter, sorter) and template renders(highlight, template eval) by a group of configurable callbacks. 262 | * Support **AMD** 263 | 264 | #### Removed features 265 | 266 | * Filter by local data and remote (by ajax) data at the same time. 267 | * Caching 268 | * Mouse event 269 | 270 | #### Changed settings 271 | 272 | `-` mean removed option 273 | `+` mean new added option 274 | The one that start without `-` or `+` mean not change. 275 | 276 | * `-` data: [], 277 | * `+` data: null, 278 | 279 | * `-` choose: "data-value", 280 | * `+` search_key: "name", 281 | 282 | * `-` callback: null, 283 | * `+` callbacks: DEFAULT_CALLBACKS, 284 | 285 | * `+` display_timeout: 300, 286 | 287 | * `-` tpl: _DEFAULT_TPL 288 | * `+` tpl: DEFAULT_TPL 289 | 290 | * `-` cache: false 291 | 292 | Not change settings 293 | 294 | * cache: true, 295 | * limit: 5, 296 | * display_flag: true, 297 | 298 | ### v0.1.7 299 | 300 | 同步 `jquery-atwho-rails` gem 的版本号 301 | 这会是 `v0.1` 的固定版本. 不再有新功能更新. 302 | 303 | ###v0.1.2 2012-3-23 304 | * box showing above instead of bottom when it get close to the bottom of window 305 | * coffeescript here is. 306 | * every registered character able to have thire own options such as template(`tpl`) 307 | * every inputor (textarea, input) able to have their own registered character and different behavior 308 | even the same character to other inputor 309 | 310 | ###v0.1.0 311 | * 可以監聽多個字符 312 | multiple char listening. 313 | * 顯示缺省列表. 314 | show default list. 315 | -------------------------------------------------------------------------------- /dist/js/jquery.atwho.min.js: -------------------------------------------------------------------------------- 1 | var DEFAULT_CALLBACKS,KEY_CODE;KEY_CODE={ESC:27,TAB:9,ENTER:13,CTRL:17,A:65,P:80,N:78,LEFT:37,UP:38,RIGHT:39,DOWN:40,BACKSPACE:8,SPACE:32},DEFAULT_CALLBACKS={beforeSave:function(t){return Controller.arrayToDefaultHash(t)},matcher:function(t,e,i,r){var n,o,s,a,l;return t=t.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&"),i&&(t="(?:^|\\s)"+t),n=decodeURI("%C3%80"),o=decodeURI("%C3%BF"),l=r?" ":"",a=new RegExp(t+"([A-Za-z"+n+"-"+o+"0-9_"+l+"'.+-]*)$|"+t+"([^\\x00-\\xff]*)$","gi"),s=a.exec(e),s?s[2]||s[1]:null},filter:function(t,e,i){var r,n,o,s;for(r=[],n=0,s=e.length;n-1&&r.push(o);return r.sort(function(t,e){return t.atwho_order-e.atwho_order})},tplEval:function(t,e){var i,r;r=t;try{return"string"!=typeof t&&(r=t(e)),r.replace(/\$\{([^\}]*)\}/g,function(t,i,r){return e[i]})}catch(n){return i=n,""}},highlighter:function(t,e){var i;return e?(i=new RegExp(">\\s*([^<]*?)("+e.replace("+","\\+")+")([^<]*)\\s*<","ig"),t.replace(i,function(t,e,i,r){return"> "+e+""+i+""+r+" <"})):t},beforeInsert:function(t,e,i){return t},beforeReposition:function(t){return t},afterMatchFailed:function(t,e){}};var App;App=function(){function t(t){this.currentFlag=null,this.controllers={},this.aliasMaps={},this.$inputor=$(t),this.setupRootElement(),this.listen()}return t.prototype.createContainer=function(t){var e;return null!=(e=this.$el)&&e.remove(),$(t.body).append(this.$el=$("
    "))},t.prototype.setupRootElement=function(t,e){var i;if(null==e&&(e=!1),t)this.window=t.contentWindow,this.document=t.contentDocument||this.window.document,this.iframe=t;else{this.document=this.$inputor[0].ownerDocument,this.window=this.document.defaultView||this.document.parentWindow;try{this.iframe=this.window.frameElement}catch(r){if(i=r,this.iframe=null,$.fn.atwho.debug)throw new Error("iframe auto-discovery is failed.\nPlease use `setIframe` to set the target iframe manually.\n"+i)}}return this.createContainer((this.iframeAsRoot=e)?this.document:document)},t.prototype.controller=function(t){var e,i,r,n;if(this.aliasMaps[t])i=this.controllers[this.aliasMaps[t]];else{n=this.controllers;for(r in n)if(e=n[r],r===t){i=e;break}}return i?i:this.controllers[this.currentFlag]},t.prototype.setContextFor=function(t){return this.currentFlag=t,this},t.prototype.reg=function(t,e){var i,r;return r=(i=this.controllers)[t]||(i[t]=this.$inputor.is("[contentEditable]")?new EditableController(this,t):new TextareaController(this,t)),e.alias&&(this.aliasMaps[e.alias]=t),r.init(e),this},t.prototype.listen=function(){return this.$inputor.on("compositionstart",function(t){return function(e){var i;return null!=(i=t.controller())&&i.view.hide(),t.isComposing=!0,null}}(this)).on("compositionend",function(t){return function(e){return t.isComposing=!1,setTimeout(function(){return t.dispatch(e)}),null}}(this)).on("keyup.atwhoInner",function(t){return function(e){return t.onKeyup(e)}}(this)).on("keydown.atwhoInner",function(t){return function(e){return t.onKeydown(e)}}(this)).on("blur.atwhoInner",function(t){return function(e){var i;if(i=t.controller())return i.expectedQueryCBId=null,i.view.hide(e,i.getOpt("displayTimeout"))}}(this)).on("click.atwhoInner",function(t){return function(e){return t.dispatch(e)}}(this)).on("scroll.atwhoInner",function(t){return function(){var e;return e=t.$inputor.scrollTop(),function(i){var r,n;return r=i.target.scrollTop,e!==r&&null!=(n=t.controller())&&n.view.hide(i),e=r,!0}}}(this)())},t.prototype.shutdown=function(){var t,e,i;i=this.controllers;for(t in i)e=i[t],e.destroy(),delete this.controllers[t];return this.$inputor.off(".atwhoInner"),this.$el.remove()},t.prototype.dispatch=function(t){var e,i,r,n;r=this.controllers,n=[];for(e in r)i=r[e],n.push(i.lookUp(t));return n},t.prototype.onKeyup=function(t){var e;switch(t.keyCode){case KEY_CODE.ESC:t.preventDefault(),null!=(e=this.controller())&&e.view.hide();break;case KEY_CODE.DOWN:case KEY_CODE.UP:case KEY_CODE.CTRL:$.noop();break;case KEY_CODE.P:case KEY_CODE.N:t.ctrlKey||this.dispatch(t);break;default:this.dispatch(t)}},t.prototype.onKeydown=function(t){var e,i;if(i=null!=(e=this.controller())?e.view:void 0,i&&i.visible())switch(t.keyCode){case KEY_CODE.ESC:t.preventDefault(),i.hide(t);break;case KEY_CODE.UP:t.preventDefault(),i.prev();break;case KEY_CODE.DOWN:t.preventDefault(),i.next();break;case KEY_CODE.P:if(!t.ctrlKey)return;t.preventDefault(),i.prev();break;case KEY_CODE.N:if(!t.ctrlKey)return;t.preventDefault(),i.next();break;case KEY_CODE.TAB:case KEY_CODE.ENTER:case KEY_CODE.SPACE:if(!i.visible())return;if(!this.controller().getOpt("spaceSelectsMatch")&&t.keyCode===KEY_CODE.SPACE)return;if(!this.controller().getOpt("tabSelectsMatch")&&t.keyCode===KEY_CODE.TAB)return;i.highlighted()?(t.preventDefault(),i.choose(t)):i.hide(t);break;default:$.noop()}},t}();var Controller,slice=[].slice;Controller=function(){function t(t,e){this.app=t,this.at=e,this.$inputor=this.app.$inputor,this.id=this.$inputor[0].id||this.uid(),this.expectedQueryCBId=null,this.setting=null,this.query=null,this.pos=0,this.range=null,0===(this.$el=$("#atwho-ground-"+this.id,this.app.$el)).length&&this.app.$el.append(this.$el=$("
    ")),this.model=new Model(this),this.view=new View(this)}return t.prototype.uid=function(){return(Math.random().toString(16)+"000000000").substr(2,8)+(new Date).getTime()},t.prototype.init=function(t){return this.setting=$.extend({},this.setting||$.fn.atwho["default"],t),this.view.init(),this.model.reload(this.setting.data)},t.prototype.destroy=function(){return this.trigger("beforeDestroy"),this.model.destroy(),this.view.destroy(),this.$el.remove()},t.prototype.callDefault=function(){var t,e,i;i=arguments[0],t=2<=arguments.length?slice.call(arguments,1):[];try{return DEFAULT_CALLBACKS[i].apply(this,t)}catch(r){return e=r,$.error(e+" Or maybe At.js doesn't have function "+i)}},t.prototype.trigger=function(t,e){var i,r;return null==e&&(e=[]),e.push(this),i=this.getOpt("alias"),r=i?t+"-"+i+".atwho":t+".atwho",this.$inputor.trigger(r,e)},t.prototype.callbacks=function(t){return this.getOpt("callbacks")[t]||DEFAULT_CALLBACKS[t]},t.prototype.getOpt=function(t,e){var i;try{return this.setting[t]}catch(r){return i=r,null}},t.prototype.insertContentFor=function(t){var e,i;return i=this.getOpt("insertTpl"),e=$.extend({},t.data("item-data"),{"atwho-at":this.at}),this.callbacks("tplEval").call(this,i,e,"onInsert")},t.prototype.renderView=function(t){var e;return e=this.getOpt("searchKey"),t=this.callbacks("sorter").call(this,this.query.text,t.slice(0,1001),e),this.view.render(t.slice(0,this.getOpt("limit")))},t.arrayToDefaultHash=function(t){var e,i,r,n;if(!$.isArray(t))return t;for(n=[],e=0,r=t.length;e0?this.renderView(this.constructor.arrayToDefaultHash(e)):this.view.hide()},this.expectedQueryCBId=this._generateQueryCBId(),this.model.query(t.text,$.proxy(e,this,this.expectedQueryCBId))},t}();var TextareaController,extend=function(t,e){function i(){this.constructor=t}for(var r in e)hasProp.call(e,r)&&(t[r]=e[r]);return i.prototype=e.prototype,t.prototype=new i,t.__super__=e.prototype,t},hasProp={}.hasOwnProperty;TextareaController=function(t){function e(){return e.__super__.constructor.apply(this,arguments)}return extend(e,t),e.prototype.catchQuery=function(){var t,e,i,r,n,o,s;if(e=this.$inputor.val(),t=this.$inputor.caret("pos",{iframe:this.app.iframe}),s=e.slice(0,t),n=this.callbacks("matcher").call(this,this.at,s,this.getOpt("startWithSpace"),this.getOpt("acceptSpaceBar")),r="string"==typeof n,!(r&&n.length0)return t.getRangeAt(0)},e.prototype._setRange=function(t,e,i){if(null==i&&(i=this._getRange()),i&&e)return e=$(e)[0],"after"===t?(i.setEndAfter(e),i.setStartAfter(e)):(i.setEndBefore(e),i.setStartBefore(e)),i.collapse(!1),this._clearRange(i)},e.prototype._clearRange=function(t){var e;if(null==t&&(t=this._getRange()),e=this.app.window.getSelection(),null==this.ctrl_a_pressed)return e.removeAllRanges(),e.addRange(t)},e.prototype._movingEvent=function(t){var e;return"click"===t.type||(e=t.which)===KEY_CODE.RIGHT||e===KEY_CODE.LEFT||e===KEY_CODE.UP||e===KEY_CODE.DOWN},e.prototype._unwrap=function(t){var e;return t=$(t).unwrap().get(0),(e=t.nextSibling)&&e.nodeValue&&(t.nodeValue+=e.nodeValue,$(e).remove()),t},e.prototype.catchQuery=function(t){var e,i,r,n,o,s,a,l,h,u,c,p;if((p=this._getRange())&&p.collapsed&&t){if(t.which===KEY_CODE.ENTER)return(i=$(p.startContainer).closest(".atwho-query")).contents().unwrap(),i.is(":empty")&&i.remove(),(i=$(".atwho-query",this.app.document)).text(i.text()).contents().last().unwrap(),void this._clearRange();if(/firefox/i.test(navigator.userAgent)){if($(p.startContainer).is(this.$inputor))return void this._clearRange();t.which===KEY_CODE.BACKSPACE&&p.startContainer.nodeType===document.ELEMENT_NODE&&(h=p.startOffset-1)>=0?(r=p.cloneRange(),r.setStart(p.startContainer,h),$(r.cloneContents()).contents().last().is(".atwho-inserted")&&(o=$(p.startContainer).contents().get(h),this._setRange("after",$(o).contents().last()))):t.which===KEY_CODE.LEFT&&p.startContainer.nodeType===document.TEXT_NODE&&(e=$(p.startContainer.previousSibling),e.is(".atwho-inserted")&&0===p.startOffset&&this._setRange("after",e.contents().last()))}if($(p.startContainer).closest(".atwho-inserted").addClass("atwho-query").siblings().removeClass("atwho-query"),(i=$(".atwho-query",this.app.document)).length>0&&i.is(":empty")&&0===i.text().length&&i.remove(),this._movingEvent(t)||i.removeClass("atwho-inserted"),i.length>0)switch(t.which){case KEY_CODE.LEFT:return this._setRange("before",i.get(0),p),void i.removeClass("atwho-query");case KEY_CODE.RIGHT:return this._setRange("after",i.get(0).nextSibling,p),void i.removeClass("atwho-query")}if(i.length>0&&(c=i.attr("data-atwho-at-query"))&&(i.empty().html(c).attr("data-atwho-at-query",null),this._setRange("after",i.get(0),p)),r=p.cloneRange(),r.setStart(p.startContainer,0),l=this.callbacks("matcher").call(this,this.at,r.toString(),this.getOpt("startWithSpace"),this.getOpt("acceptSpaceBar")),s="string"==typeof l,0===i.length&&s&&(n=p.startOffset-this.at.length-l.length)>=0&&(p.setStart(p.startContainer,n),i=$("",this.app.document).attr(this.getOpt("editableAtwhoQueryAttrs")).addClass("atwho-query"),p.surroundContents(i.get(0)),a=i.contents().last().get(0),a&&(/firefox/i.test(navigator.userAgent)?(p.setStart(a,a.length),p.setEnd(a,a.length),this._clearRange(p)):this._setRange("after",a,p))),!(s&&l.length=0&&(this._movingEvent(t)&&i.hasClass("atwho-inserted")?i.removeClass("atwho-query"):!1!==this.callbacks("afterMatchFailed").call(this,this.at,i)&&this._setRange("after",this._unwrap(i.text(i.text()).contents().first()))),null)}},e.prototype.rect=function(){var t,e,i;if(i=this.query.el.offset(),i&&this.query.el[0].getClientRects().length)return this.app.iframe&&!this.app.iframeAsRoot&&(e=(t=$(this.app.iframe)).offset(),i.left+=e.left-this.$inputor.scrollLeft(),i.top+=e.top-this.$inputor.scrollTop()),i.bottom=i.top+this.query.el.height(),i},e.prototype.insert=function(t,e){var i,r,n,o,s;return this.$inputor.is(":focus")||this.$inputor.focus(),r=this.getOpt("functionOverrides"),r.insert?r.insert.call(this,t,e):(o=""===(o=this.getOpt("suffix"))?o:o||" ",i=e.data("item-data"),this.query.el.removeClass("atwho-query").addClass("atwho-inserted").html(t).attr("data-atwho-at-query",""+i["atwho-at"]+this.query.text),(n=this._getRange())&&(this.query.el.length&&n.setEndAfter(this.query.el[0]),n.collapse(!1),n.insertNode(s=this.app.document.createTextNode("​"+o)),this._setRange("after",s,n)),this.$inputor.is(":focus")||this.$inputor.focus(),this.$inputor.change())},e}(Controller);var Model;Model=function(){function t(t){this.context=t,this.at=this.context.at,this.storage=this.context.$inputor}return t.prototype.destroy=function(){return this.storage.data(this.at,null)},t.prototype.saved=function(){return this.fetch()>0},t.prototype.query=function(t,e){var i,r,n;return r=this.fetch(),n=this.context.getOpt("searchKey"),r=this.context.callbacks("filter").call(this.context,t,r,n)||[],i=this.context.callbacks("remoteFilter"),r.length>0||!i&&0===r.length?e(r):i.call(this.context,t,e)},t.prototype.fetch=function(){return this.storage.data(this.at)||[]},t.prototype.save=function(t){return this.storage.data(this.at,this.context.callbacks("beforeSave").call(this.context,t||[]))},t.prototype.load=function(t){if(!this.saved()&&t)return this._load(t)},t.prototype.reload=function(t){return this._load(t)},t.prototype._load=function(t){return"string"==typeof t?$.ajax(t,{dataType:"json"}).done(function(t){return function(e){return t.save(e)}}(this)):this.save(t)},t}();var View;View=function(){function t(t){this.context=t,this.$el=$("
      "),this.$elUl=this.$el.children(),this.timeoutID=null,this.context.$el.append(this.$el),this.bindEvent()}return t.prototype.init=function(){var t,e;return e=this.context.getOpt("alias")||this.context.at.charCodeAt(0),t=this.context.getOpt("headerTpl"),t&&1===this.$el.children().length&&this.$el.prepend(t),this.$el.attr({id:"at-view-"+e})},t.prototype.destroy=function(){return this.$el.remove()},t.prototype.bindEvent=function(){var t,e,i;return t=this.$el.find("ul"),e=0,i=0,t.on("mousemove.atwho-view","li",function(r){return function(r){var n;if((e!==r.clientX||i!==r.clientY)&&(e=r.clientX,i=r.clientY,n=$(r.currentTarget),!n.hasClass("cur")))return t.find(".cur").removeClass("cur"),n.addClass("cur")}}(this)).on("click.atwho-view","li",function(e){return function(i){return t.find(".cur").removeClass("cur"),$(i.currentTarget).addClass("cur"),e.choose(i),i.preventDefault()}}(this))},t.prototype.visible=function(){return $.expr.filters.visible(this.$el[0])},t.prototype.highlighted=function(){return this.$el.find(".cur").length>0},t.prototype.choose=function(t){var e,i;if((e=this.$el.find(".cur")).length&&(i=this.context.insertContentFor(e),this.context._stopDelayedCall(),this.context.insert(this.context.callbacks("beforeInsert").call(this.context,i,e,t),e),this.context.trigger("inserted",[e,t]),this.hide(t)),this.context.getOpt("hideWithoutSuffix"))return this.stopShowing=!0},t.prototype.reposition=function(t){var e,i,r,n;return e=this.context.app.iframeAsRoot?this.context.app.window:window,t.bottom+this.$el.height()-$(e).scrollTop()>$(e).height()&&(t.bottom=t.top-this.$el.height()),t.left>(r=$(e).width()-this.$el.width()-5)&&(t.left=r),i={left:t.left,top:t.bottom},null!=(n=this.context.callbacks("beforeReposition"))&&n.call(this.context,i),this.$el.offset(i),this.context.trigger("reposition",[i])},t.prototype.next=function(){var t,e,i,r;return t=this.$el.find(".cur").removeClass("cur"),e=t.next(),e.length||(e=this.$el.find("li:first")),e.addClass("cur"),i=e[0],r=i.offsetTop+i.offsetHeight+(i.nextSibling?i.nextSibling.offsetHeight:0),this.scrollTop(Math.max(0,r-this.$el.height()))},t.prototype.prev=function(){var t,e,i,r;return t=this.$el.find(".cur").removeClass("cur"),i=t.prev(),i.length||(i=this.$el.find("li:last")),i.addClass("cur"),r=i[0],e=r.offsetTop+r.offsetHeight+(r.nextSibling?r.nextSibling.offsetHeight:0),this.scrollTop(Math.max(0,e-this.$el.height()))},t.prototype.scrollTop=function(t){var e;return e=this.context.getOpt("scrollDuration"),e?this.$elUl.animate({scrollTop:t},e):this.$elUl.scrollTop(t)},t.prototype.show=function(){var t;return this.stopShowing?void(this.stopShowing=!1):(this.visible()||(this.$el.show(),this.$el.scrollTop(0),this.context.trigger("shown")),(t=this.context.rect())?this.reposition(t):void 0)},t.prototype.hide=function(t,e){var i;if(this.visible())return isNaN(e)?(this.$el.hide(),this.context.trigger("hidden",[t])):(i=function(t){return function(){return t.hide()}}(this),clearTimeout(this.timeoutID),this.timeoutID=setTimeout(i,e))},t.prototype.render=function(t){var e,i,r,n,o,s,a;if(!($.isArray(t)&&t.length>0))return void this.hide();for(this.$el.find("ul").empty(),i=this.$el.find("ul"),a=this.context.getOpt("displayTpl"),r=0,o=t.length;r${name}",insertTpl:"${atwho-at}${name}",headerTpl:null,callbacks:DEFAULT_CALLBACKS,functionOverrides:{},searchKey:"name",suffix:void 0,hideWithoutSuffix:!1,startWithSpace:!0,acceptSpaceBar:!1,highlightFirst:!0,limit:5,maxLen:20,minLen:0,displayTimeout:300,delay:null,spaceSelectsMatch:!1,tabSelectsMatch:!0,editableAtwhoQueryAttrs:{},scrollDuration:150,suspendOnComposing:!0,lookUpOnClick:!0},$.fn.atwho.debug=!1; -------------------------------------------------------------------------------- /dist/js/jquery.atwho.js: -------------------------------------------------------------------------------- 1 | /** 2 | * at.js - 1.5.7 3 | * Copyright (c) 2018 chord.luo ; 4 | * Homepage: http://ichord.github.com/At.js 5 | * License: MIT 6 | */ 7 | (function (root, factory) { 8 | if (typeof define === 'function' && define.amd) { 9 | // AMD. Register as an anonymous module unless amdModuleId is set 10 | define(["jquery"], function (a0) { 11 | return (factory(a0)); 12 | }); 13 | } else if (typeof exports === 'object') { 14 | // Node. Does not work with strict CommonJS, but 15 | // only CommonJS-like environments that support module.exports, 16 | // like Node. 17 | module.exports = factory(require("jquery")); 18 | } else { 19 | factory(jQuery); 20 | } 21 | }(this, function ($) { 22 | 23 | var DEFAULT_CALLBACKS, KEY_CODE; 24 | 25 | KEY_CODE = { 26 | ESC: 27, 27 | TAB: 9, 28 | ENTER: 13, 29 | CTRL: 17, 30 | A: 65, 31 | P: 80, 32 | N: 78, 33 | LEFT: 37, 34 | UP: 38, 35 | RIGHT: 39, 36 | DOWN: 40, 37 | BACKSPACE: 8, 38 | SPACE: 32 39 | }; 40 | 41 | DEFAULT_CALLBACKS = { 42 | beforeSave: function(data) { 43 | return Controller.arrayToDefaultHash(data); 44 | }, 45 | matcher: function(flag, subtext, should_startWithSpace, acceptSpaceBar) { 46 | var _a, _y, match, regexp, space; 47 | flag = flag.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); 48 | if (should_startWithSpace) { 49 | flag = '(?:^|\\s)' + flag; 50 | } 51 | _a = decodeURI("%C3%80"); 52 | _y = decodeURI("%C3%BF"); 53 | space = acceptSpaceBar ? "\ " : ""; 54 | regexp = new RegExp(flag + "([A-Za-z" + _a + "-" + _y + "0-9_" + space + "\'\.\+\-]*)$|" + flag + "([^\\x00-\\xff]*)$", 'gi'); 55 | match = regexp.exec(subtext); 56 | if (match) { 57 | return match[2] || match[1]; 58 | } else { 59 | return null; 60 | } 61 | }, 62 | filter: function(query, data, searchKey) { 63 | var _results, i, item, len; 64 | _results = []; 65 | for (i = 0, len = data.length; i < len; i++) { 66 | item = data[i]; 67 | if (~new String(item[searchKey]).toLowerCase().indexOf(query.toLowerCase())) { 68 | _results.push(item); 69 | } 70 | } 71 | return _results; 72 | }, 73 | remoteFilter: null, 74 | sorter: function(query, items, searchKey) { 75 | var _results, i, item, len; 76 | if (!query) { 77 | return items; 78 | } 79 | _results = []; 80 | for (i = 0, len = items.length; i < len; i++) { 81 | item = items[i]; 82 | item.atwho_order = new String(item[searchKey]).toLowerCase().indexOf(query.toLowerCase()); 83 | if (item.atwho_order > -1) { 84 | _results.push(item); 85 | } 86 | } 87 | return _results.sort(function(a, b) { 88 | return a.atwho_order - b.atwho_order; 89 | }); 90 | }, 91 | tplEval: function(tpl, map) { 92 | var error, template; 93 | template = tpl; 94 | try { 95 | if (typeof tpl !== 'string') { 96 | template = tpl(map); 97 | } 98 | return template.replace(/\$\{([^\}]*)\}/g, function(tag, key, pos) { 99 | return map[key]; 100 | }); 101 | } catch (error1) { 102 | error = error1; 103 | return ""; 104 | } 105 | }, 106 | highlighter: function(li, query) { 107 | var regexp; 108 | if (!query) { 109 | return li; 110 | } 111 | regexp = new RegExp(">\\s*([^\<]*?)(" + query.replace("+", "\\+") + ")([^\<]*)\\s*<", 'ig'); 112 | return li.replace(regexp, function(str, $1, $2, $3) { 113 | return '> ' + $1 + '' + $2 + '' + $3 + ' <'; 114 | }); 115 | }, 116 | beforeInsert: function(value, $li, e) { 117 | return value; 118 | }, 119 | beforeReposition: function(offset) { 120 | return offset; 121 | }, 122 | afterMatchFailed: function(at, el) {} 123 | }; 124 | 125 | var App; 126 | 127 | App = (function() { 128 | function App(inputor) { 129 | this.currentFlag = null; 130 | this.controllers = {}; 131 | this.aliasMaps = {}; 132 | this.$inputor = $(inputor); 133 | this.setupRootElement(); 134 | this.listen(); 135 | } 136 | 137 | App.prototype.createContainer = function(doc) { 138 | var ref; 139 | if ((ref = this.$el) != null) { 140 | ref.remove(); 141 | } 142 | return $(doc.body).append(this.$el = $("
      ")); 143 | }; 144 | 145 | App.prototype.setupRootElement = function(iframe, asRoot) { 146 | var error; 147 | if (asRoot == null) { 148 | asRoot = false; 149 | } 150 | if (iframe) { 151 | this.window = iframe.contentWindow; 152 | this.document = iframe.contentDocument || this.window.document; 153 | this.iframe = iframe; 154 | } else { 155 | this.document = this.$inputor[0].ownerDocument; 156 | this.window = this.document.defaultView || this.document.parentWindow; 157 | try { 158 | this.iframe = this.window.frameElement; 159 | } catch (error1) { 160 | error = error1; 161 | this.iframe = null; 162 | if ($.fn.atwho.debug) { 163 | throw new Error("iframe auto-discovery is failed.\nPlease use `setIframe` to set the target iframe manually.\n" + error); 164 | } 165 | } 166 | } 167 | return this.createContainer((this.iframeAsRoot = asRoot) ? this.document : document); 168 | }; 169 | 170 | App.prototype.controller = function(at) { 171 | var c, current, currentFlag, ref; 172 | if (this.aliasMaps[at]) { 173 | current = this.controllers[this.aliasMaps[at]]; 174 | } else { 175 | ref = this.controllers; 176 | for (currentFlag in ref) { 177 | c = ref[currentFlag]; 178 | if (currentFlag === at) { 179 | current = c; 180 | break; 181 | } 182 | } 183 | } 184 | if (current) { 185 | return current; 186 | } else { 187 | return this.controllers[this.currentFlag]; 188 | } 189 | }; 190 | 191 | App.prototype.setContextFor = function(at) { 192 | this.currentFlag = at; 193 | return this; 194 | }; 195 | 196 | App.prototype.reg = function(flag, setting) { 197 | var base, controller; 198 | controller = (base = this.controllers)[flag] || (base[flag] = this.$inputor.is('[contentEditable]') ? new EditableController(this, flag) : new TextareaController(this, flag)); 199 | if (setting.alias) { 200 | this.aliasMaps[setting.alias] = flag; 201 | } 202 | controller.init(setting); 203 | return this; 204 | }; 205 | 206 | App.prototype.listen = function() { 207 | return this.$inputor.on('compositionstart', (function(_this) { 208 | return function(e) { 209 | var ref; 210 | if ((ref = _this.controller()) != null) { 211 | ref.view.hide(); 212 | } 213 | _this.isComposing = true; 214 | return null; 215 | }; 216 | })(this)).on('compositionend', (function(_this) { 217 | return function(e) { 218 | _this.isComposing = false; 219 | setTimeout(function() { 220 | return _this.dispatch(e); 221 | }); 222 | return null; 223 | }; 224 | })(this)).on('keyup.atwhoInner', (function(_this) { 225 | return function(e) { 226 | return _this.onKeyup(e); 227 | }; 228 | })(this)).on('keydown.atwhoInner', (function(_this) { 229 | return function(e) { 230 | return _this.onKeydown(e); 231 | }; 232 | })(this)).on('blur.atwhoInner', (function(_this) { 233 | return function(e) { 234 | var c; 235 | if (c = _this.controller()) { 236 | c.expectedQueryCBId = null; 237 | return c.view.hide(e, c.getOpt("displayTimeout")); 238 | } 239 | }; 240 | })(this)).on('click.atwhoInner', (function(_this) { 241 | return function(e) { 242 | return _this.dispatch(e); 243 | }; 244 | })(this)).on('scroll.atwhoInner', (function(_this) { 245 | return function() { 246 | var lastScrollTop; 247 | lastScrollTop = _this.$inputor.scrollTop(); 248 | return function(e) { 249 | var currentScrollTop, ref; 250 | currentScrollTop = e.target.scrollTop; 251 | if (lastScrollTop !== currentScrollTop) { 252 | if ((ref = _this.controller()) != null) { 253 | ref.view.hide(e); 254 | } 255 | } 256 | lastScrollTop = currentScrollTop; 257 | return true; 258 | }; 259 | }; 260 | })(this)()); 261 | }; 262 | 263 | App.prototype.shutdown = function() { 264 | var _, c, ref; 265 | ref = this.controllers; 266 | for (_ in ref) { 267 | c = ref[_]; 268 | c.destroy(); 269 | delete this.controllers[_]; 270 | } 271 | this.$inputor.off('.atwhoInner'); 272 | return this.$el.remove(); 273 | }; 274 | 275 | App.prototype.dispatch = function(e) { 276 | var _, c, ref, results; 277 | ref = this.controllers; 278 | results = []; 279 | for (_ in ref) { 280 | c = ref[_]; 281 | results.push(c.lookUp(e)); 282 | } 283 | return results; 284 | }; 285 | 286 | App.prototype.onKeyup = function(e) { 287 | var ref; 288 | switch (e.keyCode) { 289 | case KEY_CODE.ESC: 290 | e.preventDefault(); 291 | if ((ref = this.controller()) != null) { 292 | ref.view.hide(); 293 | } 294 | break; 295 | case KEY_CODE.DOWN: 296 | case KEY_CODE.UP: 297 | case KEY_CODE.CTRL: 298 | $.noop(); 299 | break; 300 | case KEY_CODE.P: 301 | case KEY_CODE.N: 302 | if (!e.ctrlKey) { 303 | this.dispatch(e); 304 | } 305 | break; 306 | default: 307 | this.dispatch(e); 308 | } 309 | }; 310 | 311 | App.prototype.onKeydown = function(e) { 312 | var ref, view; 313 | view = (ref = this.controller()) != null ? ref.view : void 0; 314 | if (!(view && view.visible())) { 315 | return; 316 | } 317 | switch (e.keyCode) { 318 | case KEY_CODE.ESC: 319 | e.preventDefault(); 320 | view.hide(e); 321 | break; 322 | case KEY_CODE.UP: 323 | e.preventDefault(); 324 | view.prev(); 325 | break; 326 | case KEY_CODE.DOWN: 327 | e.preventDefault(); 328 | view.next(); 329 | break; 330 | case KEY_CODE.P: 331 | if (!e.ctrlKey) { 332 | return; 333 | } 334 | e.preventDefault(); 335 | view.prev(); 336 | break; 337 | case KEY_CODE.N: 338 | if (!e.ctrlKey) { 339 | return; 340 | } 341 | e.preventDefault(); 342 | view.next(); 343 | break; 344 | case KEY_CODE.TAB: 345 | case KEY_CODE.ENTER: 346 | case KEY_CODE.SPACE: 347 | if (!view.visible()) { 348 | return; 349 | } 350 | if (!this.controller().getOpt('spaceSelectsMatch') && e.keyCode === KEY_CODE.SPACE) { 351 | return; 352 | } 353 | if (!this.controller().getOpt('tabSelectsMatch') && e.keyCode === KEY_CODE.TAB) { 354 | return; 355 | } 356 | if (view.highlighted()) { 357 | e.preventDefault(); 358 | view.choose(e); 359 | } else { 360 | view.hide(e); 361 | } 362 | break; 363 | default: 364 | $.noop(); 365 | } 366 | }; 367 | 368 | return App; 369 | 370 | })(); 371 | 372 | var Controller, 373 | slice = [].slice; 374 | 375 | Controller = (function() { 376 | Controller.prototype.uid = function() { 377 | return (Math.random().toString(16) + "000000000").substr(2, 8) + (new Date().getTime()); 378 | }; 379 | 380 | function Controller(app, at1) { 381 | this.app = app; 382 | this.at = at1; 383 | this.$inputor = this.app.$inputor; 384 | this.id = this.$inputor[0].id || this.uid(); 385 | this.expectedQueryCBId = null; 386 | this.setting = null; 387 | this.query = null; 388 | this.pos = 0; 389 | this.range = null; 390 | if ((this.$el = $("#atwho-ground-" + this.id, this.app.$el)).length === 0) { 391 | this.app.$el.append(this.$el = $("
      ")); 392 | } 393 | this.model = new Model(this); 394 | this.view = new View(this); 395 | } 396 | 397 | Controller.prototype.init = function(setting) { 398 | this.setting = $.extend({}, this.setting || $.fn.atwho["default"], setting); 399 | this.view.init(); 400 | return this.model.reload(this.setting.data); 401 | }; 402 | 403 | Controller.prototype.destroy = function() { 404 | this.trigger('beforeDestroy'); 405 | this.model.destroy(); 406 | this.view.destroy(); 407 | return this.$el.remove(); 408 | }; 409 | 410 | Controller.prototype.callDefault = function() { 411 | var args, error, funcName; 412 | funcName = arguments[0], args = 2 <= arguments.length ? slice.call(arguments, 1) : []; 413 | try { 414 | return DEFAULT_CALLBACKS[funcName].apply(this, args); 415 | } catch (error1) { 416 | error = error1; 417 | return $.error(error + " Or maybe At.js doesn't have function " + funcName); 418 | } 419 | }; 420 | 421 | Controller.prototype.trigger = function(name, data) { 422 | var alias, eventName; 423 | if (data == null) { 424 | data = []; 425 | } 426 | data.push(this); 427 | alias = this.getOpt('alias'); 428 | eventName = alias ? name + "-" + alias + ".atwho" : name + ".atwho"; 429 | return this.$inputor.trigger(eventName, data); 430 | }; 431 | 432 | Controller.prototype.callbacks = function(funcName) { 433 | return this.getOpt("callbacks")[funcName] || DEFAULT_CALLBACKS[funcName]; 434 | }; 435 | 436 | Controller.prototype.getOpt = function(at, default_value) { 437 | var e; 438 | try { 439 | return this.setting[at]; 440 | } catch (error1) { 441 | e = error1; 442 | return null; 443 | } 444 | }; 445 | 446 | Controller.prototype.insertContentFor = function($li) { 447 | var data, tpl; 448 | tpl = this.getOpt('insertTpl'); 449 | data = $.extend({}, $li.data('item-data'), { 450 | 'atwho-at': this.at 451 | }); 452 | return this.callbacks("tplEval").call(this, tpl, data, "onInsert"); 453 | }; 454 | 455 | Controller.prototype.renderView = function(data) { 456 | var searchKey; 457 | searchKey = this.getOpt("searchKey"); 458 | data = this.callbacks("sorter").call(this, this.query.text, data.slice(0, 1001), searchKey); 459 | return this.view.render(data.slice(0, this.getOpt('limit'))); 460 | }; 461 | 462 | Controller.arrayToDefaultHash = function(data) { 463 | var i, item, len, results; 464 | if (!$.isArray(data)) { 465 | return data; 466 | } 467 | results = []; 468 | for (i = 0, len = data.length; i < len; i++) { 469 | item = data[i]; 470 | if ($.isPlainObject(item)) { 471 | results.push(item); 472 | } else { 473 | results.push({ 474 | name: item 475 | }); 476 | } 477 | } 478 | return results; 479 | }; 480 | 481 | Controller.prototype.lookUp = function(e) { 482 | var query, wait; 483 | if (e && e.type === 'click' && !this.getOpt('lookUpOnClick')) { 484 | return; 485 | } 486 | if (this.getOpt('suspendOnComposing') && this.app.isComposing) { 487 | return; 488 | } 489 | query = this.catchQuery(e); 490 | if (!query) { 491 | this.expectedQueryCBId = null; 492 | return query; 493 | } 494 | this.app.setContextFor(this.at); 495 | if (wait = this.getOpt('delay')) { 496 | this._delayLookUp(query, wait); 497 | } else { 498 | this._lookUp(query); 499 | } 500 | return query; 501 | }; 502 | 503 | Controller.prototype._delayLookUp = function(query, wait) { 504 | var now, remaining; 505 | now = Date.now ? Date.now() : new Date().getTime(); 506 | this.previousCallTime || (this.previousCallTime = now); 507 | remaining = wait - (now - this.previousCallTime); 508 | if ((0 < remaining && remaining < wait)) { 509 | this.previousCallTime = now; 510 | this._stopDelayedCall(); 511 | return this.delayedCallTimeout = setTimeout((function(_this) { 512 | return function() { 513 | _this.previousCallTime = 0; 514 | _this.delayedCallTimeout = null; 515 | return _this._lookUp(query); 516 | }; 517 | })(this), wait); 518 | } else { 519 | this._stopDelayedCall(); 520 | if (this.previousCallTime !== now) { 521 | this.previousCallTime = 0; 522 | } 523 | return this._lookUp(query); 524 | } 525 | }; 526 | 527 | Controller.prototype._stopDelayedCall = function() { 528 | if (this.delayedCallTimeout) { 529 | clearTimeout(this.delayedCallTimeout); 530 | return this.delayedCallTimeout = null; 531 | } 532 | }; 533 | 534 | Controller.prototype._generateQueryCBId = function() { 535 | return {}; 536 | }; 537 | 538 | Controller.prototype._lookUp = function(query) { 539 | var _callback; 540 | _callback = function(queryCBId, data) { 541 | if (queryCBId !== this.expectedQueryCBId) { 542 | return; 543 | } 544 | if (data && data.length > 0) { 545 | return this.renderView(this.constructor.arrayToDefaultHash(data)); 546 | } else { 547 | return this.view.hide(); 548 | } 549 | }; 550 | this.expectedQueryCBId = this._generateQueryCBId(); 551 | return this.model.query(query.text, $.proxy(_callback, this, this.expectedQueryCBId)); 552 | }; 553 | 554 | return Controller; 555 | 556 | })(); 557 | 558 | var TextareaController, 559 | extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, 560 | hasProp = {}.hasOwnProperty; 561 | 562 | TextareaController = (function(superClass) { 563 | extend(TextareaController, superClass); 564 | 565 | function TextareaController() { 566 | return TextareaController.__super__.constructor.apply(this, arguments); 567 | } 568 | 569 | TextareaController.prototype.catchQuery = function() { 570 | var caretPos, content, end, isString, query, start, subtext; 571 | content = this.$inputor.val(); 572 | caretPos = this.$inputor.caret('pos', { 573 | iframe: this.app.iframe 574 | }); 575 | subtext = content.slice(0, caretPos); 576 | query = this.callbacks("matcher").call(this, this.at, subtext, this.getOpt('startWithSpace'), this.getOpt("acceptSpaceBar")); 577 | isString = typeof query === 'string'; 578 | if (isString && query.length < this.getOpt('minLen', 0)) { 579 | return; 580 | } 581 | if (isString && query.length <= this.getOpt('maxLen', 20)) { 582 | start = caretPos - query.length; 583 | end = start + query.length; 584 | this.pos = start; 585 | query = { 586 | 'text': query, 587 | 'headPos': start, 588 | 'endPos': end 589 | }; 590 | this.trigger("matched", [this.at, query.text]); 591 | } else { 592 | query = null; 593 | this.view.hide(); 594 | } 595 | return this.query = query; 596 | }; 597 | 598 | TextareaController.prototype.rect = function() { 599 | var c, iframeOffset, scaleBottom; 600 | if (!(c = this.$inputor.caret('offset', this.pos - 1, { 601 | iframe: this.app.iframe 602 | }))) { 603 | return; 604 | } 605 | if (this.app.iframe && !this.app.iframeAsRoot) { 606 | iframeOffset = $(this.app.iframe).offset(); 607 | c.left += iframeOffset.left; 608 | c.top += iframeOffset.top; 609 | } 610 | scaleBottom = this.app.document.selection ? 0 : 2; 611 | return { 612 | left: c.left, 613 | top: c.top, 614 | bottom: c.top + c.height + scaleBottom 615 | }; 616 | }; 617 | 618 | TextareaController.prototype.insert = function(content, $li) { 619 | var $inputor, source, startStr, suffix, text; 620 | $inputor = this.$inputor; 621 | source = $inputor.val(); 622 | startStr = source.slice(0, Math.max(this.query.headPos - this.at.length, 0)); 623 | suffix = (suffix = this.getOpt('suffix')) === "" ? suffix : suffix || " "; 624 | content += suffix; 625 | text = "" + startStr + content + (source.slice(this.query['endPos'] || 0)); 626 | $inputor.val(text); 627 | $inputor.caret('pos', startStr.length + content.length, { 628 | iframe: this.app.iframe 629 | }); 630 | if (!$inputor.is(':focus')) { 631 | $inputor.focus(); 632 | } 633 | return $inputor.change(); 634 | }; 635 | 636 | return TextareaController; 637 | 638 | })(Controller); 639 | 640 | var EditableController, 641 | extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }, 642 | hasProp = {}.hasOwnProperty; 643 | 644 | EditableController = (function(superClass) { 645 | extend(EditableController, superClass); 646 | 647 | function EditableController() { 648 | return EditableController.__super__.constructor.apply(this, arguments); 649 | } 650 | 651 | EditableController.prototype._getRange = function() { 652 | var sel; 653 | sel = this.app.window.getSelection(); 654 | if (sel.rangeCount > 0) { 655 | return sel.getRangeAt(0); 656 | } 657 | }; 658 | 659 | EditableController.prototype._setRange = function(position, node, range) { 660 | if (range == null) { 661 | range = this._getRange(); 662 | } 663 | if (!(range && node)) { 664 | return; 665 | } 666 | node = $(node)[0]; 667 | if (position === 'after') { 668 | range.setEndAfter(node); 669 | range.setStartAfter(node); 670 | } else { 671 | range.setEndBefore(node); 672 | range.setStartBefore(node); 673 | } 674 | range.collapse(false); 675 | return this._clearRange(range); 676 | }; 677 | 678 | EditableController.prototype._clearRange = function(range) { 679 | var sel; 680 | if (range == null) { 681 | range = this._getRange(); 682 | } 683 | sel = this.app.window.getSelection(); 684 | if (this.ctrl_a_pressed == null) { 685 | sel.removeAllRanges(); 686 | return sel.addRange(range); 687 | } 688 | }; 689 | 690 | EditableController.prototype._movingEvent = function(e) { 691 | var ref; 692 | return e.type === 'click' || ((ref = e.which) === KEY_CODE.RIGHT || ref === KEY_CODE.LEFT || ref === KEY_CODE.UP || ref === KEY_CODE.DOWN); 693 | }; 694 | 695 | EditableController.prototype._unwrap = function(node) { 696 | var next; 697 | node = $(node).unwrap().get(0); 698 | if ((next = node.nextSibling) && next.nodeValue) { 699 | node.nodeValue += next.nodeValue; 700 | $(next).remove(); 701 | } 702 | return node; 703 | }; 704 | 705 | EditableController.prototype.catchQuery = function(e) { 706 | var $inserted, $query, _range, index, inserted, isString, lastNode, matched, offset, query, query_content, range; 707 | if (!(range = this._getRange())) { 708 | return; 709 | } 710 | if (!range.collapsed) { 711 | return; 712 | } 713 | if (!e) { 714 | return; 715 | } 716 | if (e.which === KEY_CODE.ENTER) { 717 | ($query = $(range.startContainer).closest('.atwho-query')).contents().unwrap(); 718 | if ($query.is(':empty')) { 719 | $query.remove(); 720 | } 721 | ($query = $(".atwho-query", this.app.document)).text($query.text()).contents().last().unwrap(); 722 | this._clearRange(); 723 | return; 724 | } 725 | if (/firefox/i.test(navigator.userAgent)) { 726 | if ($(range.startContainer).is(this.$inputor)) { 727 | this._clearRange(); 728 | return; 729 | } 730 | if (e.which === KEY_CODE.BACKSPACE && range.startContainer.nodeType === document.ELEMENT_NODE && (offset = range.startOffset - 1) >= 0) { 731 | _range = range.cloneRange(); 732 | _range.setStart(range.startContainer, offset); 733 | if ($(_range.cloneContents()).contents().last().is('.atwho-inserted')) { 734 | inserted = $(range.startContainer).contents().get(offset); 735 | this._setRange('after', $(inserted).contents().last()); 736 | } 737 | } else if (e.which === KEY_CODE.LEFT && range.startContainer.nodeType === document.TEXT_NODE) { 738 | $inserted = $(range.startContainer.previousSibling); 739 | if ($inserted.is('.atwho-inserted') && range.startOffset === 0) { 740 | this._setRange('after', $inserted.contents().last()); 741 | } 742 | } 743 | } 744 | $(range.startContainer).closest('.atwho-inserted').addClass('atwho-query').siblings().removeClass('atwho-query'); 745 | if (($query = $(".atwho-query", this.app.document)).length > 0 && $query.is(':empty') && $query.text().length === 0) { 746 | $query.remove(); 747 | } 748 | if (!this._movingEvent(e)) { 749 | $query.removeClass('atwho-inserted'); 750 | } 751 | if ($query.length > 0) { 752 | switch (e.which) { 753 | case KEY_CODE.LEFT: 754 | this._setRange('before', $query.get(0), range); 755 | $query.removeClass('atwho-query'); 756 | return; 757 | case KEY_CODE.RIGHT: 758 | this._setRange('after', $query.get(0).nextSibling, range); 759 | $query.removeClass('atwho-query'); 760 | return; 761 | } 762 | } 763 | if ($query.length > 0 && (query_content = $query.attr('data-atwho-at-query'))) { 764 | $query.empty().html(query_content).attr('data-atwho-at-query', null); 765 | this._setRange('after', $query.get(0), range); 766 | } 767 | _range = range.cloneRange(); 768 | _range.setStart(range.startContainer, 0); 769 | matched = this.callbacks("matcher").call(this, this.at, _range.toString(), this.getOpt('startWithSpace'), this.getOpt("acceptSpaceBar")); 770 | isString = typeof matched === 'string'; 771 | if ($query.length === 0 && isString && (index = range.startOffset - this.at.length - matched.length) >= 0) { 772 | range.setStart(range.startContainer, index); 773 | $query = $('', this.app.document).attr(this.getOpt("editableAtwhoQueryAttrs")).addClass('atwho-query'); 774 | range.surroundContents($query.get(0)); 775 | lastNode = $query.contents().last().get(0); 776 | if (lastNode) { 777 | if (/firefox/i.test(navigator.userAgent)) { 778 | range.setStart(lastNode, lastNode.length); 779 | range.setEnd(lastNode, lastNode.length); 780 | this._clearRange(range); 781 | } else { 782 | this._setRange('after', lastNode, range); 783 | } 784 | } 785 | } 786 | if (isString && matched.length < this.getOpt('minLen', 0)) { 787 | return; 788 | } 789 | if (isString && matched.length <= this.getOpt('maxLen', 20)) { 790 | query = { 791 | text: matched, 792 | el: $query 793 | }; 794 | this.trigger("matched", [this.at, query.text]); 795 | return this.query = query; 796 | } else { 797 | this.view.hide(); 798 | this.query = { 799 | el: $query 800 | }; 801 | if ($query.text().indexOf(this.at) >= 0) { 802 | if (this._movingEvent(e) && $query.hasClass('atwho-inserted')) { 803 | $query.removeClass('atwho-query'); 804 | } else if (false !== this.callbacks('afterMatchFailed').call(this, this.at, $query)) { 805 | this._setRange("after", this._unwrap($query.text($query.text()).contents().first())); 806 | } 807 | } 808 | return null; 809 | } 810 | }; 811 | 812 | EditableController.prototype.rect = function() { 813 | var $iframe, iframeOffset, rect; 814 | rect = this.query.el.offset(); 815 | if (!(rect && this.query.el[0].getClientRects().length)) { 816 | return; 817 | } 818 | if (this.app.iframe && !this.app.iframeAsRoot) { 819 | iframeOffset = ($iframe = $(this.app.iframe)).offset(); 820 | rect.left += iframeOffset.left - this.$inputor.scrollLeft(); 821 | rect.top += iframeOffset.top - this.$inputor.scrollTop(); 822 | } 823 | rect.bottom = rect.top + this.query.el.height(); 824 | return rect; 825 | }; 826 | 827 | EditableController.prototype.insert = function(content, $li) { 828 | var data, overrides, range, suffix, suffixNode; 829 | if (!this.$inputor.is(':focus')) { 830 | this.$inputor.focus(); 831 | } 832 | overrides = this.getOpt('functionOverrides'); 833 | if (overrides.insert) { 834 | return overrides.insert.call(this, content, $li); 835 | } 836 | suffix = (suffix = this.getOpt('suffix')) === "" ? suffix : suffix || "\u00A0"; 837 | data = $li.data('item-data'); 838 | this.query.el.removeClass('atwho-query').addClass('atwho-inserted').html(content).attr('data-atwho-at-query', "" + data['atwho-at'] + this.query.text); 839 | if (range = this._getRange()) { 840 | if (this.query.el.length) { 841 | range.setEndAfter(this.query.el[0]); 842 | } 843 | range.collapse(false); 844 | range.insertNode(suffixNode = this.app.document.createTextNode("\u200B" + suffix)); 845 | this._setRange('after', suffixNode, range); 846 | } 847 | if (!this.$inputor.is(':focus')) { 848 | this.$inputor.focus(); 849 | } 850 | return this.$inputor.change(); 851 | }; 852 | 853 | return EditableController; 854 | 855 | })(Controller); 856 | 857 | var Model; 858 | 859 | Model = (function() { 860 | function Model(context) { 861 | this.context = context; 862 | this.at = this.context.at; 863 | this.storage = this.context.$inputor; 864 | } 865 | 866 | Model.prototype.destroy = function() { 867 | return this.storage.data(this.at, null); 868 | }; 869 | 870 | Model.prototype.saved = function() { 871 | return this.fetch() > 0; 872 | }; 873 | 874 | Model.prototype.query = function(query, callback) { 875 | var _remoteFilter, data, searchKey; 876 | data = this.fetch(); 877 | searchKey = this.context.getOpt("searchKey"); 878 | data = this.context.callbacks('filter').call(this.context, query, data, searchKey) || []; 879 | _remoteFilter = this.context.callbacks('remoteFilter'); 880 | if (data.length > 0 || (!_remoteFilter && data.length === 0)) { 881 | return callback(data); 882 | } else { 883 | return _remoteFilter.call(this.context, query, callback); 884 | } 885 | }; 886 | 887 | Model.prototype.fetch = function() { 888 | return this.storage.data(this.at) || []; 889 | }; 890 | 891 | Model.prototype.save = function(data) { 892 | return this.storage.data(this.at, this.context.callbacks("beforeSave").call(this.context, data || [])); 893 | }; 894 | 895 | Model.prototype.load = function(data) { 896 | if (!(this.saved() || !data)) { 897 | return this._load(data); 898 | } 899 | }; 900 | 901 | Model.prototype.reload = function(data) { 902 | return this._load(data); 903 | }; 904 | 905 | Model.prototype._load = function(data) { 906 | if (typeof data === "string") { 907 | return $.ajax(data, { 908 | dataType: "json" 909 | }).done((function(_this) { 910 | return function(data) { 911 | return _this.save(data); 912 | }; 913 | })(this)); 914 | } else { 915 | return this.save(data); 916 | } 917 | }; 918 | 919 | return Model; 920 | 921 | })(); 922 | 923 | var View; 924 | 925 | View = (function() { 926 | function View(context) { 927 | this.context = context; 928 | this.$el = $("
        "); 929 | this.$elUl = this.$el.children(); 930 | this.timeoutID = null; 931 | this.context.$el.append(this.$el); 932 | this.bindEvent(); 933 | } 934 | 935 | View.prototype.init = function() { 936 | var header_tpl, id; 937 | id = this.context.getOpt("alias") || this.context.at.charCodeAt(0); 938 | header_tpl = this.context.getOpt("headerTpl"); 939 | if (header_tpl && this.$el.children().length === 1) { 940 | this.$el.prepend(header_tpl); 941 | } 942 | return this.$el.attr({ 943 | 'id': "at-view-" + id 944 | }); 945 | }; 946 | 947 | View.prototype.destroy = function() { 948 | return this.$el.remove(); 949 | }; 950 | 951 | View.prototype.bindEvent = function() { 952 | var $menu, lastCoordX, lastCoordY; 953 | $menu = this.$el.find('ul'); 954 | lastCoordX = 0; 955 | lastCoordY = 0; 956 | return $menu.on('mousemove.atwho-view', 'li', (function(_this) { 957 | return function(e) { 958 | var $cur; 959 | if (lastCoordX === e.clientX && lastCoordY === e.clientY) { 960 | return; 961 | } 962 | lastCoordX = e.clientX; 963 | lastCoordY = e.clientY; 964 | $cur = $(e.currentTarget); 965 | if ($cur.hasClass('cur')) { 966 | return; 967 | } 968 | $menu.find('.cur').removeClass('cur'); 969 | return $cur.addClass('cur'); 970 | }; 971 | })(this)).on('click.atwho-view', 'li', (function(_this) { 972 | return function(e) { 973 | $menu.find('.cur').removeClass('cur'); 974 | $(e.currentTarget).addClass('cur'); 975 | _this.choose(e); 976 | return e.preventDefault(); 977 | }; 978 | })(this)); 979 | }; 980 | 981 | View.prototype.visible = function() { 982 | return $.expr.filters.visible(this.$el[0]); 983 | }; 984 | 985 | View.prototype.highlighted = function() { 986 | return this.$el.find(".cur").length > 0; 987 | }; 988 | 989 | View.prototype.choose = function(e) { 990 | var $li, content; 991 | if (($li = this.$el.find(".cur")).length) { 992 | content = this.context.insertContentFor($li); 993 | this.context._stopDelayedCall(); 994 | this.context.insert(this.context.callbacks("beforeInsert").call(this.context, content, $li, e), $li); 995 | this.context.trigger("inserted", [$li, e]); 996 | this.hide(e); 997 | } 998 | if (this.context.getOpt("hideWithoutSuffix")) { 999 | return this.stopShowing = true; 1000 | } 1001 | }; 1002 | 1003 | View.prototype.reposition = function(rect) { 1004 | var _window, offset, overflowOffset, ref; 1005 | _window = this.context.app.iframeAsRoot ? this.context.app.window : window; 1006 | if (rect.bottom + this.$el.height() - $(_window).scrollTop() > $(_window).height()) { 1007 | rect.bottom = rect.top - this.$el.height(); 1008 | } 1009 | if (rect.left > (overflowOffset = $(_window).width() - this.$el.width() - 5)) { 1010 | rect.left = overflowOffset; 1011 | } 1012 | offset = { 1013 | left: rect.left, 1014 | top: rect.bottom 1015 | }; 1016 | if ((ref = this.context.callbacks("beforeReposition")) != null) { 1017 | ref.call(this.context, offset); 1018 | } 1019 | this.$el.offset(offset); 1020 | return this.context.trigger("reposition", [offset]); 1021 | }; 1022 | 1023 | View.prototype.next = function() { 1024 | var cur, next, nextEl, offset; 1025 | cur = this.$el.find('.cur').removeClass('cur'); 1026 | next = cur.next(); 1027 | if (!next.length) { 1028 | next = this.$el.find('li:first'); 1029 | } 1030 | next.addClass('cur'); 1031 | nextEl = next[0]; 1032 | offset = nextEl.offsetTop + nextEl.offsetHeight + (nextEl.nextSibling ? nextEl.nextSibling.offsetHeight : 0); 1033 | return this.scrollTop(Math.max(0, offset - this.$el.height())); 1034 | }; 1035 | 1036 | View.prototype.prev = function() { 1037 | var cur, offset, prev, prevEl; 1038 | cur = this.$el.find('.cur').removeClass('cur'); 1039 | prev = cur.prev(); 1040 | if (!prev.length) { 1041 | prev = this.$el.find('li:last'); 1042 | } 1043 | prev.addClass('cur'); 1044 | prevEl = prev[0]; 1045 | offset = prevEl.offsetTop + prevEl.offsetHeight + (prevEl.nextSibling ? prevEl.nextSibling.offsetHeight : 0); 1046 | return this.scrollTop(Math.max(0, offset - this.$el.height())); 1047 | }; 1048 | 1049 | View.prototype.scrollTop = function(scrollTop) { 1050 | var scrollDuration; 1051 | scrollDuration = this.context.getOpt('scrollDuration'); 1052 | if (scrollDuration) { 1053 | return this.$elUl.animate({ 1054 | scrollTop: scrollTop 1055 | }, scrollDuration); 1056 | } else { 1057 | return this.$elUl.scrollTop(scrollTop); 1058 | } 1059 | }; 1060 | 1061 | View.prototype.show = function() { 1062 | var rect; 1063 | if (this.stopShowing) { 1064 | this.stopShowing = false; 1065 | return; 1066 | } 1067 | if (!this.visible()) { 1068 | this.$el.show(); 1069 | this.$el.scrollTop(0); 1070 | this.context.trigger('shown'); 1071 | } 1072 | if (rect = this.context.rect()) { 1073 | return this.reposition(rect); 1074 | } 1075 | }; 1076 | 1077 | View.prototype.hide = function(e, time) { 1078 | var callback; 1079 | if (!this.visible()) { 1080 | return; 1081 | } 1082 | if (isNaN(time)) { 1083 | this.$el.hide(); 1084 | return this.context.trigger('hidden', [e]); 1085 | } else { 1086 | callback = (function(_this) { 1087 | return function() { 1088 | return _this.hide(); 1089 | }; 1090 | })(this); 1091 | clearTimeout(this.timeoutID); 1092 | return this.timeoutID = setTimeout(callback, time); 1093 | } 1094 | }; 1095 | 1096 | View.prototype.render = function(list) { 1097 | var $li, $ul, i, item, len, li, tpl; 1098 | if (!($.isArray(list) && list.length > 0)) { 1099 | this.hide(); 1100 | return; 1101 | } 1102 | this.$el.find('ul').empty(); 1103 | $ul = this.$el.find('ul'); 1104 | tpl = this.context.getOpt('displayTpl'); 1105 | for (i = 0, len = list.length; i < len; i++) { 1106 | item = list[i]; 1107 | item = $.extend({}, item, { 1108 | 'atwho-at': this.context.at 1109 | }); 1110 | li = this.context.callbacks("tplEval").call(this.context, tpl, item, "onDisplay"); 1111 | $li = $(this.context.callbacks("highlighter").call(this.context, li, this.context.query.text)); 1112 | $li.data("item-data", item); 1113 | $ul.append($li); 1114 | } 1115 | this.show(); 1116 | if (this.context.getOpt('highlightFirst')) { 1117 | return $ul.find("li:first").addClass("cur"); 1118 | } 1119 | }; 1120 | 1121 | return View; 1122 | 1123 | })(); 1124 | 1125 | var Api; 1126 | 1127 | Api = { 1128 | load: function(at, data) { 1129 | var c; 1130 | if (c = this.controller(at)) { 1131 | return c.model.load(data); 1132 | } 1133 | }, 1134 | isSelecting: function() { 1135 | var ref; 1136 | return !!((ref = this.controller()) != null ? ref.view.visible() : void 0); 1137 | }, 1138 | hide: function() { 1139 | var ref; 1140 | return (ref = this.controller()) != null ? ref.view.hide() : void 0; 1141 | }, 1142 | reposition: function() { 1143 | var c; 1144 | if (c = this.controller()) { 1145 | return c.view.reposition(c.rect()); 1146 | } 1147 | }, 1148 | setIframe: function(iframe, asRoot) { 1149 | this.setupRootElement(iframe, asRoot); 1150 | return null; 1151 | }, 1152 | run: function() { 1153 | return this.dispatch(); 1154 | }, 1155 | destroy: function() { 1156 | this.shutdown(); 1157 | return this.$inputor.data('atwho', null); 1158 | } 1159 | }; 1160 | 1161 | $.fn.atwho = function(method) { 1162 | var _args, result; 1163 | _args = arguments; 1164 | result = null; 1165 | this.filter('textarea, input, [contenteditable=""], [contenteditable=true]').each(function() { 1166 | var $this, app; 1167 | if (!(app = ($this = $(this)).data("atwho"))) { 1168 | $this.data('atwho', (app = new App(this))); 1169 | } 1170 | if (typeof method === 'object' || !method) { 1171 | return app.reg(method.at, method); 1172 | } else if (Api[method] && app) { 1173 | return result = Api[method].apply(app, Array.prototype.slice.call(_args, 1)); 1174 | } else { 1175 | return $.error("Method " + method + " does not exist on jQuery.atwho"); 1176 | } 1177 | }); 1178 | if (result != null) { 1179 | return result; 1180 | } else { 1181 | return this; 1182 | } 1183 | }; 1184 | 1185 | $.fn.atwho["default"] = { 1186 | at: void 0, 1187 | alias: void 0, 1188 | data: null, 1189 | displayTpl: "
      • ${name}
      • ", 1190 | insertTpl: "${atwho-at}${name}", 1191 | headerTpl: null, 1192 | callbacks: DEFAULT_CALLBACKS, 1193 | functionOverrides: {}, 1194 | searchKey: "name", 1195 | suffix: void 0, 1196 | hideWithoutSuffix: false, 1197 | startWithSpace: true, 1198 | acceptSpaceBar: false, 1199 | highlightFirst: true, 1200 | limit: 5, 1201 | maxLen: 20, 1202 | minLen: 0, 1203 | displayTimeout: 300, 1204 | delay: null, 1205 | spaceSelectsMatch: false, 1206 | tabSelectsMatch: true, 1207 | editableAtwhoQueryAttrs: {}, 1208 | scrollDuration: 150, 1209 | suspendOnComposing: true, 1210 | lookUpOnClick: true 1211 | }; 1212 | 1213 | $.fn.atwho.debug = false; 1214 | 1215 | })); 1216 | --------------------------------------------------------------------------------