├── Readme.md ├── app ├── client │ ├── app.coffee │ ├── models.coffee │ └── views.coffee ├── css │ ├── app.less │ ├── bootstrap.less │ ├── forms.less │ ├── mixins.less │ ├── patterns.less │ ├── reset.less │ ├── scaffolding.less │ ├── tables.less │ ├── type.less │ └── variables.less ├── server │ └── app.coffee ├── shared │ └── util.coffee └── views │ └── app.jade ├── config ├── app.coffee ├── db.coffee ├── events.coffee └── http.coffee ├── lib ├── client │ ├── 01.jquery.min.js │ ├── 02.jquery.tmpl.min.js │ ├── 03.helpers.js │ ├── 04.underscore.js │ ├── 05.backbone.js │ ├── 06.codemirror.js │ ├── 07.bootstrap-dropdown.js │ ├── 08.bootstrap-modal.js │ ├── 09.bootstrap-alerts.js │ ├── 10.bootstrap-buttons.js │ ├── 11.bootbox.js │ ├── 12.clike.js │ ├── 12.clojure.js │ ├── 12.coffeescript.js │ ├── 12.css.js │ ├── 12.diff.js │ ├── 12.gfm.js │ ├── 12.groovy.js │ ├── 12.haskell.js │ ├── 12.htmlembedded.js │ ├── 12.htmlmixed.js │ ├── 12.javascript.js │ ├── 12.jinja2.js │ ├── 12.less.js │ ├── 12.lua.js │ ├── 12.markdown.js │ ├── 12.mysql.js │ ├── 12.ntriples.js │ ├── 12.pascal.js │ ├── 12.perl.js │ ├── 12.php.js │ ├── 12.plsql.js │ ├── 12.python.js │ ├── 12.r.js │ ├── 12.rst.js │ ├── 12.ruby.js │ ├── 12.rust.js │ ├── 12.scheme.js │ ├── 12.smalltalk.js │ ├── 12.sparql.js │ ├── 12.stex.js │ ├── 12.tiddlywiki.js │ ├── 12.velocity.js │ ├── 12.verilog.js │ ├── 12.xml.js │ ├── 12.xmlpure.js │ ├── 12.yaml.js │ ├── 13.overlay.js │ ├── 14.swfobject.js │ └── 15.downloadify.min.js └── css │ ├── cobalt.css │ ├── codemirror.css │ ├── eclipse.css │ ├── elegant.css │ ├── monokai.css │ ├── neat.css │ ├── night.css │ └── reset.css ├── package.json └── public ├── favicon.ico ├── images ├── download.png └── logo.png └── media └── downloadify.swf /Readme.md: -------------------------------------------------------------------------------- 1 | # snucode 2 | 3 | snucode is a basic realtime-collaborative text editor with syntax highlighting. Check out the live demo at [snucode.com](http://snucode.com) 4 | 5 | Contact me: [@werg](http://twitter.com/werg) 6 | 7 | ## Dependencies 8 | 9 | snucode uses the Node.js websockets framework [SocketStream](https://github.com/socketstream/socketstream) (v 0.2 for now), and the syntax-highlighted editor [CodeMirror](http://codemirror.net/) as well as a bit of [Backbone.js](http://documentcloud.github.com/backbone/) for MVC structure on the client side. 10 | 11 | ## Features 12 | 13 | * Arbitrarily many authors can **edit** the same file **simultaneously**. 14 | * All **syntax-highlighting** modes [CodeMirror](http://codemirror.net/) provides are supported, 15 | * as well as most of the **visual themes**. 16 | * **Author attribution** is done by decorating text with color-coded underlining. 17 | * **Open files** from local file system using the HTML 5 [File API](http://www.html5rocks.com/en/tutorials/file/dndfiles/). 18 | * Tries to **guess filetype** from file extension or MIME type. 19 | * **Line Wrapping** optional. 20 | * **Save files** to disk using Douglas Neiner's [Downloadify](https://github.com/dcneiner/Downloadify) (uses Flash since there's no reliable HTML 5 solution). 21 | * **Documents expire** a set amount of time after last access (default two weeks, using Redis expire). 22 | 23 | For bugs or feature requests please either send me a [tweet](http://twitter.com/werg) or raise an issue on [github](https://github.com/werg/snucode/issues). 24 | 25 | ## Install 26 | 27 | To roll your own copy, install Node.js and SocketStream [at version 0.2](https://github.com/socketstream/socketstream/tree/0.2) (which currently still should be default version available on npm). 28 | To test it simply run 29 | socketstream start 30 | 31 | And head to [0.0.0.0:3000](http://0.0.0.0:3000). 32 | 33 | ## Operational Transformation 34 | 35 | I chose a rather simplifying and (to my mind elegant) approach to operational transformation (i.e. making sure that concurrent edits get integrated at the right place in the document). You can read up on it at my [weblog](http://gpickard.wordpress.com/2012/02/17/my-approach-to-operational-transformation/). 36 | 37 | ## License 38 | 39 | Copyright (c) 2012 Gabriel Pickard 40 | 41 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 42 | 43 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 44 | 45 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /app/client/app.coffee: -------------------------------------------------------------------------------- 1 | 2 | # A very ugly set of functions that gives us an idea 3 | # of what time it will be when a message reaches the server 4 | window.$now = Date.now or -> new Date().getTime() 5 | syncClock = (sampleSize, cb) -> 6 | _sc = (summOffs, times) -> 7 | SS.server.app.calcOffset $now(), (offs) -> 8 | if times > 1 9 | sc = -> 10 | _sc offs + summOffs, times - 1, cb 11 | setTimeout sc, 2000 12 | else 13 | avgOffset = (offs + summOffs) / sampleSize 14 | C.app.serverNow = -> 15 | $now() + avgOffset 16 | cb() 17 | 18 | _sc(0, sampleSize) 19 | 20 | generateUserId = -> 21 | Math.random().toString(36).substring(7) 22 | 23 | #exports.user_id = Math.random().toString(36).substring(7) 24 | 25 | generateColor = -> 26 | rn = Math.random() * 73 27 | gn = Math.random() * 73 28 | bn = 123 - rn - gn 29 | colors = for c in [rn, gn, bn] 30 | (182 + c).toString(16).substring 0, 2 31 | return '#' + colors.join '' 32 | 33 | #generateColor = -> 34 | # rn = Math.random() * 127 35 | # gn = Math.random() * 127 36 | # 37 | # bn = 381 - rn - gn 38 | # colors = for c in [rn, gn, bn] 39 | # (63 + c).toString(16).substring 0, 2 40 | # return '#' + colors.join '' 41 | 42 | hideOptions = -> 43 | $('#showoptions').not(':visible').slideDown() 44 | $('#options').filter(':visible').fadeOut() 45 | $('#message').filter(':visible').fadeOut() 46 | #$('#solink').click showOptions 47 | false 48 | 49 | showOptions = -> 50 | $('#showoptions').filter(':visible').slideUp() 51 | $('#options').not(':visible').slideDown() 52 | $('#message').not(':visible').slideDown() 53 | setTimeout hideOptions, 80000 54 | false 55 | 56 | setAuthor = -> 57 | SS.server.app.setAuthor 58 | 'user_id': C.app.user_id 59 | 'color': C.app.user_color 60 | 61 | handleUserAuth = -> 62 | C.app.socket_id = SS.socket.socket.sessionid 63 | if C.app.user_id? and C.app.user_color? 64 | setAuthor() 65 | else 66 | SS.server.app.getAuthor (author) -> 67 | if author 68 | C.app.user_id = author.user_id 69 | else 70 | C.app.user_color = generateColor() 71 | C.app.user_id = generateUserId() 72 | setAuthor() 73 | 74 | 75 | # This method is called automatically when the websocket connection is established. 76 | exports.init = -> 77 | window.C = SS.client 78 | $('#solink').click showOptions 79 | $('#holink').click hideOptions 80 | syncClock 1, -> 81 | handleUserAuth() 82 | SS.socket.on 'connect', handleUserAuth 83 | 84 | C.app.route = new C.app.Router() 85 | unless Backbone.history.start {pushState: true} 86 | SS.server.app.newDocID (docid) -> 87 | C.app.route.navigate "doc/" + docid, {trigger:true, replace:true} 88 | 89 | $('#modes').change -> 90 | mode = $('#modes').val() 91 | #C.app.text.view.setMode mode 92 | C.app.route.navigate 'doc/' + C.app.text.id + '?mode=' + mode, {trigger:true, replace:true} 93 | 94 | $('#themes').change -> 95 | colors = $('#themes').val() 96 | #C.app.text.view.setMode mode 97 | C.app.text.view.setTheme colors 98 | # todo set session preference 99 | 100 | if window.File and window.FileReader 101 | $('#file').change (event) -> 102 | file = this.files[0] 103 | reader = new FileReader() 104 | reader.onload = (event) -> 105 | C.app.filename = file.name 106 | # try to determine filetype first: 107 | parts = file.name.split '.' 108 | extension = parts[parts.length-1] 109 | langname = SS.shared.util.fileExts[extension] 110 | if langname? 111 | $('#modes').val(langname) 112 | $('#modes').change() 113 | else if file.type isnt '' 114 | for m in CodeMirror.listMIMEs() 115 | if m.mime is file.type 116 | $('#modes').val(m.mode.name) 117 | $('#modes').change() 118 | 119 | # actually set the text: 120 | text = event.target.result 121 | C.app.text.view.cm.setValue text 122 | 123 | 124 | 125 | reader.readAsText file 126 | 127 | else 128 | $('#filewrapper').remove() 129 | 130 | Downloadify.create 'downloadify', 131 | filename: -> 132 | if C.app.filename? 133 | return C.app.filename 134 | else 135 | ext = 'txt' 136 | mode = $('#modes').val() 137 | if mode isnt 'null' 138 | for e,m of SS.shared.util.fileExts 139 | if mode is m 140 | ext = e 141 | return C.app.text.id + '.' + ext 142 | data: -> 143 | C.app.text.getText() 144 | onError: -> 145 | alert('You must put something in the File Contents or there will be nothing to save!') 146 | swf: '/media/downloadify.swf' 147 | downloadImage: '/images/download.png' 148 | width: 30 149 | height: 10 150 | 151 | 152 | runSync = -> 153 | syncClock 5, -> console.log 'synced clock' 154 | setInterval runSync, 10000000 155 | runSync() 156 | 157 | for mode in C.views.availableModes 158 | $('#modes').append '' 159 | 160 | showOptions() 161 | 162 | 163 | 164 | class exports.Router extends Backbone.Router 165 | routes: 166 | "doc/:id?mode=:lang": "setMode" 167 | "doc/:id": "setDoc" 168 | 169 | setMode: (id, lang) => 170 | @loadDoc id, -> 171 | if C.app.text.view.setMode(lang) and $('#modes').val() isnt lang 172 | $('#modes').val lang 173 | 174 | setDoc: (id) => 175 | @setMode id, $('#modes').val() 176 | 177 | 178 | 179 | loadDoc: (id, cb) => 180 | unless C.app.text? and C.app.text.id is id 181 | SS.server.app.loadDoc id, (chars, authors) -> 182 | # todo: race conditions if someone is 183 | # fervuously typing from the start 184 | SS.events.on 'newChange', (change, channel) -> 185 | if channel is id 186 | C.app.text.theirChange change 187 | C.app.text = new C.models.SCText chars, 188 | 'id':id 189 | 'authors':authors 190 | 191 | SS.events.on 'authorOnline', C.app.text.addAuthor 192 | 193 | authorOnline = -> 194 | SS.server.app.authorOnline C.app.text.id 195 | 196 | authorOnline() 197 | setInterval authorOnline, 30000 198 | 199 | if cb? 200 | cb(C.app.text) 201 | else if cb? 202 | cb C.app.text 203 | -------------------------------------------------------------------------------- /app/client/models.coffee: -------------------------------------------------------------------------------- 1 | genCharID = (c, serverTS) -> 2 | randid = Math.random().toString(36).substring(7) 3 | return [serverTS, SS.client.app.user_id, randid, c].join(':') 4 | 5 | class exports.Char extends Backbone.Model 6 | initialize: -> 7 | 8 | 9 | class exports.SCText extends Backbone.Collection 10 | initialize: (models, options) -> 11 | @textChanged = true 12 | @id = options.id 13 | @authors = [] 14 | for id, color of options.authors 15 | @addAuthor 16 | 'user_id': id 17 | 'color': color 18 | @view = new C.views.SCTextView {model: this} 19 | 20 | 21 | model: SS.client.models.Char 22 | 23 | comparator: (c)-> 24 | c.get 'place' 25 | 26 | calcText: => 27 | # todo check whether value is ok as kwd 28 | @text = @pluck("value").join '' 29 | @textChanged = false 30 | 31 | getText: => 32 | if @textChanged 33 | @calcText() 34 | return @text 35 | 36 | addAuthor: (author) => 37 | unless author.user_id in @authors 38 | @authors.push author.user_id 39 | @view.addAuthor author 40 | 41 | storeChange: (change, options) => 42 | # in: a list of id's in change.removeCharIDs 43 | # a list of JSON-structures in change.addChars 44 | # a timestamp 45 | # 46 | # create a new model with ID for every 47 | # entry in change.addChars 48 | # add all of them between 49 | 50 | change.addCharModels = for spec in change.addChars 51 | new C.models.Char spec, {collection: this} 52 | @add change.addCharModels, options 53 | 54 | @remove change.removeCharIDs, options 55 | 56 | @textChanged = true 57 | 58 | theirChange: (change, options) => 59 | if change.socket_id isnt C.app.socket_id 60 | @view.removeChars change.removeCharIDs, {silent: true} 61 | @remove change.removeCharIDs, options 62 | #for r in change.removeCharIDs 63 | # @remove r 64 | 65 | change.removeCharIDs = [] 66 | @storeChange change, options 67 | # change.addCharModels gets added in above function call 68 | @view.insertChars change.addCharModels, {silent: true} 69 | 70 | myChange: (change, options) => 71 | # todo a queue system? 72 | fi = change.fi 73 | ti = change.ti 74 | 75 | change.removeCharModels = @models.slice(fi, ti) 76 | 77 | wholetext = change.text.join '\n' 78 | l = wholetext.length + 1 #(plus one for jitter) 79 | 80 | l1 = ti - fi 81 | 82 | if l > 1200 or l1 > 1200 83 | alert "Unfortunately, snucode cannot yet handle inserting or removing that much text - we get all sorts of nasty behavior!" 84 | window.location.reload() 85 | 86 | start = if fi > 0 87 | @at(fi-1).get 'place' 88 | else 89 | 0.0 90 | 91 | end = if ti < @size() - 1 and @size() > 0 92 | @at(ti).get 'place' 93 | else 94 | 1.0 95 | 96 | placeinc = 0.08 * (end - start) / l 97 | place = start + 0.5 * placeinc + Math.random() * placeinc 98 | change.addChars = [] 99 | 100 | for c in wholetext 101 | cobj = 102 | 'id': genCharID c, change.timestamp # todo add now 103 | 'place': place 104 | 'value': c 105 | 'author': C.app.user_id 106 | change.addChars.push cobj 107 | place += placeinc 108 | 109 | change.removeCharIDs = [] 110 | 111 | for c in change.removeCharModels 112 | change.removeCharIDs.push c.id 113 | 114 | pushPackage = 115 | 'addChars': change.addChars 116 | 'removeCharIDs': change.removeCharIDs 117 | 'socket_id': C.app.socket_id 118 | 'textID': @id 119 | 120 | # todo: check whether ordering of the following matters: 121 | SS.server.app.pushChange pushPackage 122 | @storeChange change, options 123 | 124 | -------------------------------------------------------------------------------- /app/client/views.coffee: -------------------------------------------------------------------------------- 1 | exports.availableModes = ["null", "clike", "clojure", "coffeescript", "css", "diff", "gfm", "groovy", "haskell", "htmlembedded", "htmlmixed", "javascript", "jinja2", "less", "lua", "markdown", "mysql", "ntriples", "pascal", "perl", "php", "plsql", "python", "r", "rst", "ruby", "rust", "scheme", "smalltalk", "sparql", "stex", "tiddlywiki", "velocity", "verilog", "xml", "xmlpure", "yaml"] 2 | 3 | class exports.SCTextView extends Backbone.View 4 | initialize: -> 5 | @authors = [] 6 | @calcLineIndex() 7 | # todo: bind to change events in model 8 | @render() 9 | 10 | render: => 11 | lW = $('#linewrap').is(':checked') 12 | @theme = $('#themes').val() 13 | @cm = CodeMirror document.getElementById('content'), 14 | value: @model.getText() 15 | lineNumbers: true 16 | #'mode': @mode 17 | 'onChange': @onChange 18 | 'smartIndent': false 19 | #'theme': 'monokai' 20 | 'lineWrapping': lW 21 | 22 | @setTheme @theme 23 | 24 | @cm.focus() 25 | $('#linewrap').change => 26 | @setLineWrap $('#linewrap').is(':checked') 27 | 28 | $('#markusers').change => 29 | if $('#markusers').is(':checked') 30 | for author in @authors 31 | @markUser author 32 | else 33 | $('.authorstyle').remove() 34 | 35 | 36 | setLineWrap: (lineWrap) => 37 | @cm.setOption 'lineWrapping', lineWrap 38 | 39 | setTheme: (theme) => 40 | @cm.setOption 'theme', theme 41 | $('body').removeClass 'cm-s-' + @theme 42 | @theme = theme 43 | $('body').addClass 'cm-s-' + @theme 44 | 45 | addAuthor: (author) => 46 | @authors.push author 47 | if $('#markusers').is(':checked') 48 | @markUser author 49 | 50 | markUser: (author) => 51 | style = "" 52 | $(style).appendTo("head") 53 | 54 | setMode: (lang) => 55 | if lang in C.views.availableModes 56 | @model.lang = lang 57 | CodeMirror.defineMode "concur_" + lang, (config, parserConfig) -> 58 | mode = 59 | startState: -> 60 | state = 61 | index: 0 62 | return state 63 | 64 | token: (stream, state) -> 65 | textarea = C.app.text.view.cm.getValue().length 66 | backtext = C.app.text.size() 67 | 68 | if textarea is backtext 69 | # our model is up-to-date 70 | if state.index > backtext 71 | console.log "what's this?" 72 | cAt = C.app.text.at(state.index) 73 | unless cAt? 74 | console.log "what's that?" 75 | while cAt.get('value') is '\n' 76 | state.index += 1 77 | cAt = C.app.text.at(state.index) 78 | 79 | author = cAt.get 'author' 80 | while stream.peek() and cAt? and cAt.get('author') is author 81 | stream.next() 82 | state.index += 1 83 | cAt = C.app.text.at state.index 84 | return 'author-' + author 85 | 86 | else 87 | console.log 'we are trying to tokenize, even though the model isnt up-to-date!' 88 | # so we are dealing with stuff we wrote ourselves 89 | #for i in [0...textarea-backtext] 90 | # stream.next() 91 | #return 'author-' + C.app.user_id 92 | 93 | copyState: (state) -> 94 | return {index: state.index} 95 | 96 | 97 | return CodeMirror.overlayParser(CodeMirror.getMode(config, lang), mode, true) 98 | 99 | @cm.setOption 'mode', 'concur_' + lang 100 | return true 101 | 102 | else 103 | return false 104 | 105 | pos2model: (cmpos) => 106 | # format {ch:0, line:18} 107 | @model.at @pos2index cmpos 108 | 109 | pos2index: (cmpos) => 110 | @lineIndex[cmpos.line] + cmpos.ch 111 | 112 | index2pos: (index) => 113 | # indices of newline-characters 114 | # are at the end of the old line 115 | line = 0 116 | # check whether next line is 117 | while @lineIndex[line+1]? and @lineIndex[line+1] <= index 118 | line += 1 119 | 120 | {'line': line, ch: index - @lineIndex[line]} 121 | 122 | insertChar: (c, options) => 123 | index = @model.indexOf c 124 | pos = @index2pos index 125 | 126 | if c.get('value') is '\n' 127 | @lineIndex.splice pos.line+1, 0, index 128 | 129 | @adjustLI pos.line, 1 130 | @cm.replaceRange c.get('value'), pos, pos, options 131 | 132 | insertChars: (chars, options) => 133 | if chars.length > 0 134 | #sorted = _.sortBy chars, (c) -> 135 | # c.get 'place' 136 | # assume sorting 137 | sorted = chars 138 | 139 | initIndex = @model.indexOf sorted[0] 140 | lastIndex = @model.indexOf sorted[sorted.length-1] 141 | pos = @index2pos initIndex 142 | 143 | if lastIndex + 1 - initIndex is sorted.length 144 | text = _.map sorted, (c) -> 145 | c.get 'value' 146 | 147 | @calcLineIndex() 148 | @cm.replaceRange text.join(''), pos, pos, options 149 | #addI = 0 150 | #line = pos.line 151 | #for t, i in text 152 | # if t is '\n' 153 | # @lineIndex.splice line + 1, 0, initIndex + i 154 | else 155 | for c in chars 156 | @insertChar c 157 | 158 | removeChars: (charIDs, options) => 159 | l = charIDs.length 160 | if l > 0 161 | # here we assume sorting again 162 | initIndex = @model.indexOf @model.get charIDs[0] 163 | lastIndex = @model.indexOf @model.get charIDs[l-1] 164 | 165 | if lastIndex + 1 - initIndex is l 166 | pos = @index2pos initIndex 167 | pos1 = @index2pos lastIndex 168 | pos1.ch += 1 169 | 170 | @cm.replaceRange '', pos, pos1, options 171 | 172 | removeChar: (c, options) => 173 | 174 | cmodel = @model.get c 175 | index = @model.indexOf cmodel 176 | pos = @index2pos index 177 | line1 = pos.line 178 | ch1 = pos.ch+1 179 | 180 | if cmodel.get('value') is '\n' 181 | @lineIndex.splice pos.line+1, 1 182 | line1 += 1 183 | ch1 = 0 184 | 185 | @adjustLI pos.line, -1 186 | pos1 = {'line': line1, 'ch': ch1} 187 | 188 | @cm.replaceRange '', pos, pos1, options 189 | 190 | 191 | onChange: (editor, change) => 192 | change.timestamp = C.app.serverNow() 193 | # change has values: {from, to, text, next} 194 | 195 | change.fi = @pos2index change.from 196 | change.ti = @pos2index change.to 197 | 198 | @model.myChange change, {silent: true} 199 | 200 | @calcLineIndex() 201 | 202 | # todo check when this even happens and how order affects it!! 203 | if change.next? 204 | console.log "we actually have another change" 205 | console.log change.next 206 | @onChange editor, change.next 207 | 208 | 209 | calcLineIndex: => 210 | li = [0] 211 | @model.forEach (c, index) => 212 | if c.get('value') is '\n' 213 | li.push index+1 214 | 215 | @lineIndex = li 216 | 217 | adjustLI: (index, amount) => 218 | # adjust starting with the next index 219 | # so indexes increase after changed line 220 | #if index+1 >= @lineIndex.length 221 | # @lineIndex[index+1] = @lineIndex[index] 222 | for line in [index+1 ... @lineIndex.length] 223 | @lineIndex[line] += amount 224 | -------------------------------------------------------------------------------- /app/css/app.less: -------------------------------------------------------------------------------- 1 | // use twitter bootstrap 2 | @import "bootstrap.less"; 3 | 4 | #footer {padding:10px;border-bottom-style:solid;border-top-style:solid;border-width:1px;border-color:#eee} 5 | select {width:85px;height:100%} 6 | #message {padding:10px;} 7 | .b {font-weight:bold;} 8 | #file {height:100%;} 9 | //#linewrap {height:100%;} -------------------------------------------------------------------------------- /app/css/bootstrap.less: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap @VERSION 3 | * 4 | * Copyright 2011 Twitter, Inc 5 | * Licensed under the Apache License v2.0 6 | * http://www.apache.org/licenses/LICENSE-2.0 7 | * 8 | * Designed and built with all the love in the world @twitter by @mdo and @fat. 9 | * Date: @DATE 10 | */ 11 | 12 | // CSS Reset 13 | @import "reset.less"; 14 | 15 | // Core variables and mixins 16 | @import "variables.less"; // Modify this for custom colors, font-sizes, etc 17 | @import "mixins.less"; 18 | 19 | // Grid system and page structure 20 | @import "scaffolding.less"; 21 | 22 | // Styled patterns and elements 23 | @import "type.less"; 24 | @import "forms.less"; 25 | @import "tables.less"; 26 | @import "patterns.less"; -------------------------------------------------------------------------------- /app/css/reset.less: -------------------------------------------------------------------------------- 1 | /* Reset.less 2 | * Props to Eric Meyer (meyerweb.com) for his CSS reset file. We're using an adapted version here that cuts out some of the reset HTML elements we will never need here (i.e., dfn, samp, etc). 3 | * ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- */ 4 | 5 | 6 | // ERIC MEYER RESET 7 | // -------------------------------------------------- 8 | 9 | html, body { margin: 0; padding: 0; } 10 | h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, cite, code, del, dfn, em, img, q, s, samp, small, strike, strong, sub, sup, tt, var, dd, dl, dt, li, ol, ul, fieldset, form, label, legend, button, table, caption, tbody, tfoot, thead, tr, th, td { margin: 0; padding: 0; border: 0; font-weight: normal; font-style: normal; font-size: 100%; line-height: 1; font-family: inherit; } 11 | table { border-collapse: collapse; border-spacing: 0; } 12 | ol, ul { list-style: none; } 13 | q:before, q:after, blockquote:before, blockquote:after { content: ""; } 14 | 15 | 16 | // Normalize.css 17 | // Pulling in select resets form the normalize.css project 18 | // -------------------------------------------------- 19 | 20 | // Display in IE6-9 and FF3 21 | // ------------------------- 22 | // Source: http://github.com/necolas/normalize.css 23 | html { 24 | overflow-y: scroll; 25 | font-size: 100%; 26 | -webkit-text-size-adjust: 100%; 27 | -ms-text-size-adjust: 100%; 28 | } 29 | // Focus states 30 | a:focus { 31 | outline: thin dotted; 32 | } 33 | // Hover & Active 34 | a:hover, 35 | a:active { 36 | outline: 0; 37 | } 38 | 39 | // Display in IE6-9 and FF3 40 | // ------------------------- 41 | // Source: http://github.com/necolas/normalize.css 42 | article, 43 | aside, 44 | details, 45 | figcaption, 46 | figure, 47 | footer, 48 | header, 49 | hgroup, 50 | nav, 51 | section { 52 | display: block; 53 | } 54 | 55 | // Display block in IE6-9 and FF3 56 | // ------------------------- 57 | // Source: http://github.com/necolas/normalize.css 58 | audio, 59 | canvas, 60 | video { 61 | display: inline-block; 62 | *display: inline; 63 | *zoom: 1; 64 | } 65 | 66 | // Prevents modern browsers from displaying 'audio' without controls 67 | // ------------------------- 68 | // Source: http://github.com/necolas/normalize.css 69 | audio:not([controls]) { 70 | display: none; 71 | } 72 | 73 | // Prevents sub and sup affecting line-height in all browsers 74 | // ------------------------- 75 | // Source: http://github.com/necolas/normalize.css 76 | sub, 77 | sup { 78 | font-size: 75%; 79 | line-height: 0; 80 | position: relative; 81 | vertical-align: baseline; 82 | } 83 | sup { 84 | top: -0.5em; 85 | } 86 | sub { 87 | bottom: -0.25em; 88 | } 89 | 90 | // Img border in a's and image quality 91 | // ------------------------- 92 | // Source: http://github.com/necolas/normalize.css 93 | img { 94 | border: 0; 95 | -ms-interpolation-mode: bicubic; 96 | } 97 | 98 | // Forms 99 | // ------------------------- 100 | // Source: http://github.com/necolas/normalize.css 101 | 102 | // Font size in all browsers, margin changes, misc consistency 103 | button, 104 | input, 105 | select, 106 | textarea { 107 | font-size: 100%; 108 | margin: 0; 109 | vertical-align: baseline; 110 | *vertical-align: middle; 111 | } 112 | button, 113 | input { 114 | line-height: normal; // FF3/4 have !important on line-height in UA stylesheet 115 | *overflow: visible; // Inner spacing ie IE6/7 116 | } 117 | button::-moz-focus-inner, 118 | input::-moz-focus-inner { // Inner padding and border oddities in FF3/4 119 | border: 0; 120 | padding: 0; 121 | } 122 | button, 123 | input[type="button"], 124 | input[type="reset"], 125 | input[type="submit"] { 126 | cursor: pointer; // Cursors on all buttons applied consistently 127 | -webkit-appearance: button; // Style clicable inputs in iOS 128 | } 129 | input[type="search"] { // Appearance in Safari/Chrome 130 | -webkit-appearance: textfield; 131 | -webkit-box-sizing: content-box; 132 | -moz-box-sizing: content-box; 133 | box-sizing: content-box; 134 | } 135 | input[type="search"]::-webkit-search-decoration { 136 | -webkit-appearance: none; // Inner-padding issues in Chrome OSX, Safari 5 137 | } 138 | textarea { 139 | overflow: auto; // Remove vertical scrollbar in IE6-9 140 | vertical-align: top; // Readability and alignment cross-browser 141 | } -------------------------------------------------------------------------------- /app/css/scaffolding.less: -------------------------------------------------------------------------------- 1 | /* 2 | * Scaffolding 3 | * Basic and global styles for generating a grid system, structural layout, and page templates 4 | * ------------------------------------------------------------------------------------------- */ 5 | 6 | 7 | // STRUCTURAL LAYOUT 8 | // ----------------- 9 | 10 | body { 11 | background-color: @white; 12 | margin: 0; 13 | #font > .sans-serif(normal,@basefont,@baseline); 14 | color: @grayDark; 15 | } 16 | 17 | // Container (centered, fixed-width layouts) 18 | .container { 19 | .fixed-container(); 20 | } 21 | 22 | // Fluid layouts (left aligned, with sidebar, min- & max-width content) 23 | .container-fluid { 24 | position: relative; 25 | min-width: 940px; 26 | padding-left: 20px; 27 | padding-right: 20px; 28 | .clearfix(); 29 | > .sidebar { 30 | position: absolute; 31 | top: 0; 32 | left: 20px; 33 | width: 220px; 34 | } 35 | // TODO in v2: rename this and .popover .content to be more specific 36 | > .content { 37 | margin-left: 240px; 38 | } 39 | } 40 | 41 | 42 | // BASE STYLES 43 | // ----------- 44 | 45 | // Links 46 | a { 47 | color: @linkColor; 48 | text-decoration: none; 49 | line-height: inherit; 50 | font-weight: inherit; 51 | &:hover { 52 | color: @linkColorHover; 53 | text-decoration: underline; 54 | } 55 | } 56 | 57 | // Quick floats 58 | .pull-right { 59 | float: right; 60 | } 61 | .pull-left { 62 | float: left; 63 | } 64 | 65 | // Toggling content 66 | .hide { 67 | display: none; 68 | } 69 | .show { 70 | display: block; 71 | } 72 | 73 | 74 | // GRID SYSTEM 75 | // ----------- 76 | // To customize the grid system, bring up the variables.less file and change the column count, size, and gutter there 77 | 78 | .row { 79 | .clearfix(); 80 | margin-left: -@gridGutterWidth; 81 | } 82 | 83 | // Find all .span# classes within .row and give them the necessary properties for grid columns (supported by all browsers back to IE7) 84 | // Credit to @dhg for the idea 85 | .row > [class*="span"] { 86 | .gridColumn(); 87 | } 88 | 89 | // Default columns 90 | .span1 { .columns(1); } 91 | .span2 { .columns(2); } 92 | .span3 { .columns(3); } 93 | .span4 { .columns(4); } 94 | .span5 { .columns(5); } 95 | .span6 { .columns(6); } 96 | .span7 { .columns(7); } 97 | .span8 { .columns(8); } 98 | .span9 { .columns(9); } 99 | .span10 { .columns(10); } 100 | .span11 { .columns(11); } 101 | .span12 { .columns(12); } 102 | .span13 { .columns(13); } 103 | .span14 { .columns(14); } 104 | .span15 { .columns(15); } 105 | .span16 { .columns(16); } 106 | 107 | // For optional 24-column grid 108 | .span17 { .columns(17); } 109 | .span18 { .columns(18); } 110 | .span19 { .columns(19); } 111 | .span20 { .columns(20); } 112 | .span21 { .columns(21); } 113 | .span22 { .columns(22); } 114 | .span23 { .columns(23); } 115 | .span24 { .columns(24); } 116 | 117 | // Offset column options 118 | .row { 119 | > .offset1 { .offset(1); } 120 | > .offset2 { .offset(2); } 121 | > .offset3 { .offset(3); } 122 | > .offset4 { .offset(4); } 123 | > .offset5 { .offset(5); } 124 | > .offset6 { .offset(6); } 125 | > .offset7 { .offset(7); } 126 | > .offset8 { .offset(8); } 127 | > .offset9 { .offset(9); } 128 | > .offset10 { .offset(10); } 129 | > .offset11 { .offset(11); } 130 | > .offset12 { .offset(12); } 131 | } 132 | 133 | // Unique column sizes for 16-column grid 134 | .span-one-third { width: 300px; } 135 | .span-two-thirds { width: 620px; } 136 | .row { 137 | > .offset-one-third { margin-left: 340px; } 138 | > .offset-two-thirds { margin-left: 660px; } 139 | } -------------------------------------------------------------------------------- /app/css/tables.less: -------------------------------------------------------------------------------- 1 | /* 2 | * Tables.less 3 | * Tables for, you guessed it, tabular data 4 | * ---------------------------------------- */ 5 | 6 | 7 | // BASELINE STYLES 8 | // --------------- 9 | 10 | table { 11 | width: 100%; 12 | margin-bottom: @baseline; 13 | padding: 0; 14 | font-size: @basefont; 15 | border-collapse: collapse; 16 | th, 17 | td { 18 | padding: 10px 10px 9px; 19 | line-height: @baseline; 20 | text-align: left; 21 | } 22 | th { 23 | padding-top: 9px; 24 | font-weight: bold; 25 | vertical-align: middle; 26 | } 27 | td { 28 | vertical-align: top; 29 | border-top: 1px solid #ddd; 30 | } 31 | // When scoped to row, fix th in tbody 32 | tbody th { 33 | border-top: 1px solid #ddd; 34 | vertical-align: top; 35 | } 36 | } 37 | 38 | 39 | // CONDENSED VERSION 40 | // ----------------- 41 | .condensed-table { 42 | th, 43 | td { 44 | padding: 5px 5px 4px; 45 | } 46 | } 47 | 48 | 49 | // BORDERED VERSION 50 | // ---------------- 51 | 52 | .bordered-table { 53 | border: 1px solid #ddd; 54 | border-collapse: separate; // Done so we can round those corners! 55 | *border-collapse: collapse; /* IE7, collapse table to remove spacing */ 56 | .border-radius(4px); 57 | th + th, 58 | td + td, 59 | th + td { 60 | border-left: 1px solid #ddd; 61 | } 62 | thead tr:first-child th:first-child, 63 | tbody tr:first-child td:first-child { 64 | .border-radius(4px 0 0 0); 65 | } 66 | thead tr:first-child th:last-child, 67 | tbody tr:first-child td:last-child { 68 | .border-radius(0 4px 0 0); 69 | } 70 | tbody tr:last-child td:first-child { 71 | .border-radius(0 0 0 4px); 72 | } 73 | tbody tr:last-child td:last-child { 74 | .border-radius(0 0 4px 0); 75 | } 76 | } 77 | 78 | 79 | // TABLE CELL SIZES 80 | // ---------------- 81 | 82 | // This is a duplication of the main grid .columns() mixin, but subtracts 20px to account for input padding and border 83 | .tableColumns(@columnSpan: 1) { 84 | width: ((@gridColumnWidth - 20) * @columnSpan) + ((@gridColumnWidth - 20) * (@columnSpan - 1)); 85 | } 86 | table { 87 | // Default columns 88 | .span1 { .tableColumns(1); } 89 | .span2 { .tableColumns(2); } 90 | .span3 { .tableColumns(3); } 91 | .span4 { .tableColumns(4); } 92 | .span5 { .tableColumns(5); } 93 | .span6 { .tableColumns(6); } 94 | .span7 { .tableColumns(7); } 95 | .span8 { .tableColumns(8); } 96 | .span9 { .tableColumns(9); } 97 | .span10 { .tableColumns(10); } 98 | .span11 { .tableColumns(11); } 99 | .span12 { .tableColumns(12); } 100 | .span13 { .tableColumns(13); } 101 | .span14 { .tableColumns(14); } 102 | .span15 { .tableColumns(15); } 103 | .span16 { .tableColumns(16); } 104 | } 105 | 106 | 107 | // ZEBRA-STRIPING 108 | // -------------- 109 | 110 | // Default zebra-stripe styles (alternating gray and transparent backgrounds) 111 | .zebra-striped { 112 | tbody { 113 | tr:nth-child(odd) td, 114 | tr:nth-child(odd) th { 115 | background-color: #f9f9f9; 116 | } 117 | tr:hover td, 118 | tr:hover th { 119 | background-color: #f5f5f5; 120 | } 121 | } 122 | } 123 | 124 | table { 125 | // Tablesorting styles w/ jQuery plugin 126 | .header { 127 | cursor: pointer; 128 | &:after { 129 | content: ""; 130 | float: right; 131 | margin-top: 7px; 132 | border-width: 0 4px 4px; 133 | border-style: solid; 134 | border-color: #000 transparent; 135 | visibility: hidden; 136 | } 137 | } 138 | // Style the sorted column headers (THs) 139 | .headerSortUp, 140 | .headerSortDown { 141 | background-color: rgba(141,192,219,.25); 142 | text-shadow: 0 1px 1px rgba(255,255,255,.75); 143 | } 144 | // Style the ascending (reverse alphabetical) column header 145 | .header:hover { 146 | &:after { 147 | visibility:visible; 148 | } 149 | } 150 | // Style the descending (alphabetical) column header 151 | .headerSortDown, 152 | .headerSortDown:hover { 153 | &:after { 154 | visibility:visible; 155 | .opacity(60); 156 | } 157 | } 158 | // Style the ascending (reverse alphabetical) column header 159 | .headerSortUp { 160 | &:after { 161 | border-bottom: none; 162 | border-left: 4px solid transparent; 163 | border-right: 4px solid transparent; 164 | border-top: 4px solid #000; 165 | visibility:visible; 166 | .box-shadow(none); //can't add boxshadow to downward facing arrow :( 167 | .opacity(60); 168 | } 169 | } 170 | // Blue Table Headings 171 | .blue { 172 | color: @blue; 173 | border-bottom-color: @blue; 174 | } 175 | .headerSortUp.blue, 176 | .headerSortDown.blue { 177 | background-color: lighten(@blue, 40%); 178 | } 179 | // Green Table Headings 180 | .green { 181 | color: @green; 182 | border-bottom-color: @green; 183 | } 184 | .headerSortUp.green, 185 | .headerSortDown.green { 186 | background-color: lighten(@green, 40%); 187 | } 188 | // Red Table Headings 189 | .red { 190 | color: @red; 191 | border-bottom-color: @red; 192 | } 193 | .headerSortUp.red, 194 | .headerSortDown.red { 195 | background-color: lighten(@red, 50%); 196 | } 197 | // Yellow Table Headings 198 | .yellow { 199 | color: @yellow; 200 | border-bottom-color: @yellow; 201 | } 202 | .headerSortUp.yellow, 203 | .headerSortDown.yellow { 204 | background-color: lighten(@yellow, 40%); 205 | } 206 | // Orange Table Headings 207 | .orange { 208 | color: @orange; 209 | border-bottom-color: @orange; 210 | } 211 | .headerSortUp.orange, 212 | .headerSortDown.orange { 213 | background-color: lighten(@orange, 40%); 214 | } 215 | // Purple Table Headings 216 | .purple { 217 | color: @purple; 218 | border-bottom-color: @purple; 219 | } 220 | .headerSortUp.purple, 221 | .headerSortDown.purple { 222 | background-color: lighten(@purple, 40%); 223 | } 224 | } -------------------------------------------------------------------------------- /app/css/type.less: -------------------------------------------------------------------------------- 1 | /* Typography.less 2 | * Headings, body text, lists, code, and more for a versatile and durable typography system 3 | * ---------------------------------------------------------------------------------------- */ 4 | 5 | 6 | // BODY TEXT 7 | // --------- 8 | 9 | p { 10 | #font > .shorthand(normal,@basefont,@baseline); 11 | margin-bottom: @baseline / 2; 12 | small { 13 | font-size: @basefont - 2; 14 | color: @grayLight; 15 | } 16 | } 17 | 18 | 19 | // HEADINGS 20 | // -------- 21 | 22 | h1, h2, h3, h4, h5, h6 { 23 | font-weight: bold; 24 | color: @grayDark; 25 | small { 26 | color: @grayLight; 27 | } 28 | } 29 | h1 { 30 | margin-bottom: @baseline; 31 | font-size: 30px; 32 | line-height: @baseline * 2; 33 | small { 34 | font-size: 18px; 35 | } 36 | } 37 | h2 { 38 | font-size: 24px; 39 | line-height: @baseline * 2; 40 | small { 41 | font-size: 14px; 42 | } 43 | } 44 | h3, h4, h5, h6 { 45 | line-height: @baseline * 2; 46 | } 47 | h3 { 48 | font-size: 18px; 49 | small { 50 | font-size: 14px; 51 | } 52 | } 53 | h4 { 54 | font-size: 16px; 55 | small { 56 | font-size: 12px; 57 | } 58 | } 59 | h5 { 60 | font-size: 14px; 61 | } 62 | h6 { 63 | font-size: 13px; 64 | color: @grayLight; 65 | text-transform: uppercase; 66 | } 67 | 68 | 69 | // COLORS 70 | // ------ 71 | 72 | // Unordered and Ordered lists 73 | ul, ol { 74 | margin: 0 0 @baseline 25px; 75 | } 76 | ul ul, 77 | ul ol, 78 | ol ol, 79 | ol ul { 80 | margin-bottom: 0; 81 | } 82 | ul { 83 | list-style: disc; 84 | } 85 | ol { 86 | list-style: decimal; 87 | } 88 | li { 89 | line-height: @baseline; 90 | color: @gray; 91 | } 92 | ul.unstyled { 93 | list-style: none; 94 | margin-left: 0; 95 | } 96 | 97 | // Description Lists 98 | dl { 99 | margin-bottom: @baseline; 100 | dt, dd { 101 | line-height: @baseline; 102 | } 103 | dt { 104 | font-weight: bold; 105 | } 106 | dd { 107 | margin-left: @baseline / 2; 108 | } 109 | } 110 | 111 | // MISC 112 | // ---- 113 | 114 | // Horizontal rules 115 | hr { 116 | margin: 20px 0 19px; 117 | border: 0; 118 | border-bottom: 1px solid #eee; 119 | } 120 | 121 | // Emphasis 122 | strong { 123 | font-style: inherit; 124 | font-weight: bold; 125 | } 126 | em { 127 | font-style: italic; 128 | font-weight: inherit; 129 | line-height: inherit; 130 | } 131 | .muted { 132 | color: @grayLight; 133 | } 134 | 135 | // Blockquotes 136 | blockquote { 137 | margin-bottom: @baseline; 138 | border-left: 5px solid #eee; 139 | padding-left: 15px; 140 | p { 141 | #font > .shorthand(300,14px,@baseline); 142 | margin-bottom: 0; 143 | } 144 | small { 145 | display: block; 146 | #font > .shorthand(300,12px,@baseline); 147 | color: @grayLight; 148 | &:before { 149 | content: '\2014 \00A0'; 150 | } 151 | } 152 | } 153 | 154 | // Addresses 155 | address { 156 | display: block; 157 | line-height: @baseline; 158 | margin-bottom: @baseline; 159 | } 160 | 161 | // Inline and block code styles 162 | code, pre { 163 | padding: 0 3px 2px; 164 | font-family: Monaco, Andale Mono, Courier New, monospace; 165 | font-size: 12px; 166 | .border-radius(3px); 167 | } 168 | code { 169 | background-color: lighten(@orange, 40%); 170 | color: rgba(0,0,0,.75); 171 | padding: 1px 3px; 172 | } 173 | pre { 174 | background-color: #f5f5f5; 175 | display: block; 176 | padding: (@baseline - 1) / 2; 177 | margin: 0 0 @baseline; 178 | line-height: @baseline; 179 | font-size: 12px; 180 | border: 1px solid #ccc; 181 | border: 1px solid rgba(0,0,0,.15); 182 | .border-radius(3px); 183 | white-space: pre; 184 | white-space: pre-wrap; 185 | word-wrap: break-word; 186 | 187 | } -------------------------------------------------------------------------------- /app/css/variables.less: -------------------------------------------------------------------------------- 1 | /* Variables.less 2 | * Variables to customize the look and feel of Bootstrap 3 | * ----------------------------------------------------- */ 4 | 5 | 6 | // Links 7 | @linkColor: #0069d6; 8 | @linkColorHover: darken(@linkColor, 15); 9 | 10 | // Grays 11 | @black: #000; 12 | @grayDark: lighten(@black, 25%); 13 | @gray: lighten(@black, 50%); 14 | @grayLight: lighten(@black, 75%); 15 | @grayLighter: lighten(@black, 90%); 16 | @white: #fff; 17 | 18 | // Accent Colors 19 | @blue: #049CDB; 20 | @blueDark: #0064CD; 21 | @green: #46a546; 22 | @red: #9d261d; 23 | @yellow: #ffc40d; 24 | @orange: #f89406; 25 | @pink: #c3325f; 26 | @purple: #7a43b6; 27 | 28 | // Baseline grid 29 | @basefont: 13px; 30 | @baseline: 18px; 31 | 32 | // Griditude 33 | // Modify the grid styles in mixins.less 34 | @gridColumns: 16; 35 | @gridColumnWidth: 40px; 36 | @gridGutterWidth: 20px; 37 | @extraSpace: (@gridGutterWidth * 2); // For our grid calculations 38 | @siteWidth: (@gridColumns * @gridColumnWidth) + (@gridGutterWidth * (@gridColumns - 1)); 39 | 40 | // Color Scheme 41 | // Use this to roll your own color schemes if you like (unused by Bootstrap by default) 42 | @baseColor: @blue; // Set a base color 43 | @complement: spin(@baseColor, 180); // Determine a complementary color 44 | @split1: spin(@baseColor, 158); // Split complements 45 | @split2: spin(@baseColor, -158); 46 | @triad1: spin(@baseColor, 135); // Triads colors 47 | @triad2: spin(@baseColor, -135); 48 | @tetra1: spin(@baseColor, 90); // Tetra colors 49 | @tetra2: spin(@baseColor, -90); 50 | @analog1: spin(@baseColor, 22); // Analogs colors 51 | @analog2: spin(@baseColor, -22); 52 | 53 | 54 | 55 | // More variables coming soon: 56 | // - @basefont to @baseFontSize 57 | // - @baseline to @baseLineHeight 58 | // - @baseFontFamily 59 | // - @primaryButtonColor 60 | // - anything else? File an issue on GitHub -------------------------------------------------------------------------------- /app/server/app.coffee: -------------------------------------------------------------------------------- 1 | # Server-side Code 2 | 3 | textID2RedisKey = (id) -> 4 | "snucode:doc:" + id 5 | 6 | createDocID = (cb) -> 7 | id = Math.random().toString(36).substring(7) 8 | R.exists textID2RedisKey(id), (err, exists) -> 9 | if exists 10 | createDocID cb 11 | else 12 | # todo: this does not create a new document 13 | # so there could be race conditions 14 | cb id 15 | 16 | validColor = (color) -> 17 | numval = parseInt color.substring(1), 16 18 | color[0] is '#' and numval < 16777216 and numval >= 0 19 | 20 | validUserID = (user_id) -> 21 | result = true 22 | for c in user_id.toLowerCase().split() 23 | result = result and not isNaN parseInt c, 36 24 | return result 25 | 26 | validAuthor = (author) -> 27 | validColor(author.color) and validUserID(author.user_id) 28 | 29 | setColor = (author, textID, cb) -> 30 | colorKey = textID2RedisKey(textID) + ':colors' 31 | R.hset colorKey, author.user_id, author.color, cb 32 | 33 | 34 | exports.actions = 35 | pushChange: (change, cb) -> 36 | change.socket_id = @request.socket_id 37 | SS.publish.channel [change.textID], 'newChange', change 38 | 39 | color = @session.attributes.author.color 40 | user_id = @session.attributes.author.user_id 41 | rid = textID2RedisKey(change.textID) 42 | 43 | redis_args = [rid] 44 | for addC in change.addChars 45 | redis_args.push addC.place 46 | redis_args.push addC.id 47 | 48 | R.zadd redis_args, -> 49 | redis_args = [rid] 50 | R.zrem redis_args.concat(change.removeCharIDs), -> 51 | R.hset rid + ':color', user_id, color, -> 52 | cb() 53 | 54 | setAuthor: (author, cb) -> 55 | if validAuthor(author) 56 | unless @session.attributes? 57 | @session.attributes = {} 58 | @session.attributes.author = author 59 | @session.save -> 60 | cb true 61 | else 62 | cb false 63 | 64 | getAuthor: (cb) -> 65 | if @session.attributes.author? 66 | cb @session.attributes.author 67 | else 68 | R.incr 'snucode:stats:total:authors' 69 | cb false 70 | 71 | setColor: (textID, cb) -> 72 | setColor @session.attributes.author, textID, cb 73 | # to do broadcast some kind of color change message? 74 | 75 | 76 | authorOnline: (textID, cb) -> 77 | if @session.attributes.author? 78 | SS.publish.channel [textID], 'authorOnline', @session.attributes.author 79 | cb() 80 | 81 | newDocID: (cb) -> 82 | createDocID cb 83 | R.incr 'snucode:stats:total:docs' 84 | 85 | loadDoc: (id, cb) -> 86 | #@session.channel.unsubscribeAll() 87 | @session.channel.subscribe(id) 88 | docID = textID2RedisKey(id) 89 | 90 | # delete document after two weeks 91 | R.expire docID, 1209600 92 | R.expire docID + ':authors', 1209600 93 | R.incr 'snucode:stats:total:loads' 94 | 95 | # retrieve doc 96 | R.zrange docID, 0,-1,"withscores", (err, pchars) -> 97 | chars = [] 98 | while pchars.length > 0 99 | p = pchars.pop() 100 | id = pchars.pop() 101 | val = id[id.length-1] 102 | auth = id.split(':')[1] 103 | chars.push 104 | 'value': val 105 | 'id': id 106 | 'place': parseFloat(p) 107 | 'author': auth 108 | 109 | R.hgetall docID + ':authors', (authors) -> 110 | cb chars, authors 111 | 112 | calcOffset: (clientTime, cb) -> 113 | # todo: check whether it's Date.now 114 | cb Date.now() - clientTime 115 | 116 | -------------------------------------------------------------------------------- /app/shared/util.coffee: -------------------------------------------------------------------------------- 1 | exports.fileExts = 2 | js: 'javascript' 3 | c: 'clike' 4 | h: 'clike' 5 | cc: 'clike' 6 | cpp:'clike' 7 | clj:'clojure' 8 | coffee:'coffeescript' 9 | css:'css' 10 | hs: 'haskell' 11 | lhs:'haskell' 12 | htm:'htmlmixed' 13 | html:'htmlmixed' 14 | less:'less' 15 | md: 'markdown' 16 | pl: 'perl' 17 | php:'php' 18 | rb: 'ruby' 19 | lua:'lua' 20 | py: 'python' 21 | r: 'r' 22 | xml:'xml' 23 | tex:'stex' -------------------------------------------------------------------------------- /app/views/app.jade: -------------------------------------------------------------------------------- 1 | !!! 5 2 | html(lang="en") 3 | head 4 | != SocketStream 5 | meta(charset="utf-8") 6 | title snucode 7 | body 8 | #content 9 | #footer 10 | #showoptions.b(style='display:none') 11 | a#solink(href='#') 12 | | Options 13 | #options 14 | span#hideoptions.b 15 |   16 | a#holink(href='#') 17 | | Hide Options 18 | |    Save file:  19 | span#downloadify 20 | |    Syntax:  21 | select#modes 22 | |     Theme:  23 | select#themes 24 | option(selected, value='default') 25 | | default 26 | option(value='night') 27 | | night 28 | option(value='monokai') 29 | | monokai 30 | option(value='neat') 31 | | neat 32 | option(value='elegant') 33 | | elegant 34 | option(value='cobalt') 35 | | cobalt 36 | option(value='eclipse') 37 | | eclipse 38 | |     Wrap lines:  39 | input#linewrap(type='checkbox') 40 | |     Color-mark authors:  41 | input#markusers(type='checkbox') 42 | span#filewrapper 43 | |      Open File:  44 | input#file(type='file') 45 | p#message 46 | | Welcome to snucode, a simple collaborative realtime 47 | | editor with syntax highlighting! 48 | span.b Share the URL of this page with your co-workers to get started. 49 | br 50 | | This webapp was built using Node.js 51 | | and the realtime framework 52 | a(href="https://github.com/socketstream/socketstream") 53 | | SocketStream. 54 | | Get the code on 55 | a(href="https://github.com/werg/snucode") 56 | | github 57 | | and follow us on twitter: 58 | a(href="http://twitter.com/werg") 59 | | @werg, 60 | a(href="http://twitter.com/snucode") 61 | | @snucode. 62 | br 63 | | This is a minimal release, further features 64 | | are planed once I figure out how people are using it. 65 | | Please let me know if there's features you'd like to see. -------------------------------------------------------------------------------- /config/app.coffee: -------------------------------------------------------------------------------- 1 | # Main Application Config 2 | # ----------------------- 3 | # Optional config files can be added to /config/environments/.coffee (e.g. /config/environments/development.coffee) 4 | # giving you the opportunity to override each setting on a per-environment basis 5 | # Tip: Type 'SS.config' into the SocketStream Console to see the full list of possible config options and view the current settings 6 | 7 | exports.config = 8 | 9 | # HTTP server (becomes secondary server when HTTPS is enabled) 10 | http: 11 | port: 3000 12 | hostname: "0.0.0.0" 13 | 14 | # HTTPS server (becomes primary server if enabled) 15 | https: 16 | enabled: false 17 | port: 443 18 | domain: "www.socketstream.org" 19 | 20 | # Redis support. Must be enabled before hosting your app! 21 | # Install Redis 2.2 or above from http://redis.io/ then run 'redis-server' 22 | redis: 23 | enabled: true 24 | db_index: 0 # if you're sharing one Redis server across multiple apps, give each app a different db_index from 0 - 15 25 | 26 | # HTTP(S) request-based API module 27 | api: 28 | enabled: true 29 | prefix: 'api' 30 | https_only: false 31 | 32 | # Load balancing. Install ZeroMQ (type 'socketstream help' for info) then set suitable TCP values for your network once you're ready to run across multiple boxes 33 | #cluster: 34 | # sockets: 35 | # fe_main: "tcp://10.0.0.10:9000" 36 | # fe_pub: "tcp://10.0.0.10:9001" 37 | # be_main: "tcp://10.1.1.10:9000" 38 | -------------------------------------------------------------------------------- /config/db.coffee: -------------------------------------------------------------------------------- 1 | # Place your Database config here 2 | -------------------------------------------------------------------------------- /config/events.coffee: -------------------------------------------------------------------------------- 1 | # Server-side Events 2 | # ------------------ 3 | # Uncomment these events to run your own custom code when events are fired 4 | 5 | # SS.events.on 'client:init', (session) -> 6 | # console.log "The client with Session ID #{session.id} has initialized (loaded or reloaded the page)" 7 | 8 | # SS.events.on 'client:disconnect', (session) -> 9 | # console.log "The client with Session ID #{session.id} and User ID #{session.user_id} has disconnected" 10 | 11 | # SS.events.on 'client:heartbeat', (session) -> 12 | # console.log "The client with Session ID #{session.id} (User ID #{session.user_id}) is still alive!" 13 | 14 | # SS.events.on 'channel:subscribe', (session, channel) -> 15 | # console.log "The client with Session ID #{session.id} has subscribed to #{channel}" 16 | 17 | # SS.events.on 'channel:unsubscribe', (session, channel) -> 18 | # console.log "The client with Session ID #{session.id} has unsubscribed from #{channel}" 19 | 20 | # SS.events.on 'application:exception', (error) -> 21 | # console.log "Application exception caught: #{error.message}" -------------------------------------------------------------------------------- /config/http.coffee: -------------------------------------------------------------------------------- 1 | # HTTP Middleware Config 2 | # ---------------------- 3 | 4 | # Version 2.0 5 | 6 | # This file defines how incoming HTTP requests are handled 7 | 8 | # CUSTOM MIDDLEWARE 9 | 10 | # Hook-in your own custom HTTP middleware to modify or respond to requests before they're passed to the SocketStream HTTP stack 11 | 12 | custom = -> 13 | 14 | (request, response, next) -> 15 | # console.log 'This is my custom middleware. The URL requested is', request.url 16 | # Unless you're serving a response you'll need to call next() here 17 | next() 18 | 19 | 20 | # CONNECT MIDDLEWARE 21 | 22 | # connect = require('connect') 23 | 24 | # Stack for Primary Server 25 | exports.primary = 26 | [ 27 | #connect.logger() # example of calling in-built connect middleware. be sure to install connect in THIS project and uncomment out the line above 28 | #require('connect-i18n')() # example of using 3rd-party middleware from https://github.com/senchalabs/connect/wiki 29 | #custom # example of using your own custom middleware (using the example above) 30 | ] 31 | 32 | # Stack for Secondary Server 33 | exports.secondary = [] -------------------------------------------------------------------------------- /lib/client/02.jquery.tmpl.min.js: -------------------------------------------------------------------------------- 1 | /* 2 | * jQuery Templates Plugin 1.0.0pre 3 | * http://github.com/jquery/jquery-tmpl 4 | * Requires jQuery 1.4.2 5 | * 6 | * Copyright Software Freedom Conservancy, Inc. 7 | * Dual licensed under the MIT or GPL Version 2 licenses. 8 | * http://jquery.org/license 9 | */ 10 | (function(a){var r=a.fn.domManip,d="_tmplitem",q=/^[^<]*(<[\w\W]+>)[^>]*$|\{\{\! /,b={},f={},e,p={key:0,data:{}},i=0,c=0,l=[];function g(g,d,h,e){var c={data:e||(e===0||e===false)?e:d?d.data:{},_wrap:d?d._wrap:null,tmpl:null,parent:d||null,nodes:[],calls:u,nest:w,wrap:x,html:v,update:t};g&&a.extend(c,g,{nodes:[],parent:d});if(h){c.tmpl=h;c._ctnt=c._ctnt||c.tmpl(a,c);c.key=++i;(l.length?f:b)[i]=c}return c}a.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(f,d){a.fn[f]=function(n){var g=[],i=a(n),k,h,m,l,j=this.length===1&&this[0].parentNode;e=b||{};if(j&&j.nodeType===11&&j.childNodes.length===1&&i.length===1){i[d](this[0]);g=this}else{for(h=0,m=i.length;h0?this.clone(true):this).get();a(i[h])[d](k);g=g.concat(k)}c=0;g=this.pushStack(g,f,i.selector)}l=e;e=null;a.tmpl.complete(l);return g}});a.fn.extend({tmpl:function(d,c,b){return a.tmpl(this[0],d,c,b)},tmplItem:function(){return a.tmplItem(this[0])},template:function(b){return a.template(b,this[0])},domManip:function(d,m,k){if(d[0]&&a.isArray(d[0])){var g=a.makeArray(arguments),h=d[0],j=h.length,i=0,f;while(i").join(">").split('"').join(""").split("'").join("'")}});a.extend(a.tmpl,{tag:{tmpl:{_default:{$2:"null"},open:"if($notnull_1){__=__.concat($item.nest($1,$2));}"},wrap:{_default:{$2:"null"},open:"$item.calls(__,$1,$2);__=[];",close:"call=$item.calls();__=call._.concat($item.wrap(call,__));"},each:{_default:{$2:"$index, $value"},open:"if($notnull_1){$.each($1a,function($2){with(this){",close:"}});}"},"if":{open:"if(($notnull_1) && $1a){",close:"}"},"else":{_default:{$1:"true"},open:"}else if(($notnull_1) && $1a){"},html:{open:"if($notnull_1){__.push($1a);}"},"=":{_default:{$1:"$data"},open:"if($notnull_1){__.push($.encode($1a));}"},"!":{open:""}},complete:function(){b={}},afterManip:function(f,b,d){var e=b.nodeType===11?a.makeArray(b.childNodes):b.nodeType===1?[b]:[];d.call(f,b);m(e);c++}});function j(e,g,f){var b,c=f?a.map(f,function(a){return typeof a==="string"?e.key?a.replace(/(<\w+)(?=[\s>])(?![^>]*_tmplitem)([^>]*)/g,"$1 "+d+'="'+e.key+'" $2'):a:j(a,e,a._ctnt)}):e;if(g)return c;c=c.join("");c.replace(/^\s*([^<\s][^<]*)?(<[\w\W]+>)([^>]*[^>\s])?\s*$/,function(f,c,e,d){b=a(e).get();m(b);if(c)b=k(c).concat(b);if(d)b=b.concat(k(d))});return b?b:k(c)}function k(c){var b=document.createElement("div");b.innerHTML=c;return a.makeArray(b.childNodes)}function o(b){return new Function("jQuery","$item","var $=jQuery,call,__=[],$data=$item.data;with($data){__.push('"+a.trim(b).replace(/([\\'])/g,"\\$1").replace(/[\r\t\n]/g," ").replace(/\$\{([^\}]*)\}/g,"{{= $1}}").replace(/\{\{(\/?)(\w+|.)(?:\(((?:[^\}]|\}(?!\}))*?)?\))?(?:\s+(.*?)?)?(\(((?:[^\}]|\}(?!\}))*?)\))?\s*\}\}/g,function(m,l,k,g,b,c,d){var j=a.tmpl.tag[k],i,e,f;if(!j)throw"Unknown template tag: "+k;i=j._default||[];if(c&&!/\w$/.test(b)){b+=c;c=""}if(b){b=h(b);d=d?","+h(d)+")":c?")":"";e=c?b.indexOf(".")>-1?b+h(c):"("+b+").call($item"+d:b;f=c?e:"(typeof("+b+")==='function'?("+b+").call($item):("+b+"))"}else f=e=i.$1||"null";g=h(g);return"');"+j[l?"close":"open"].split("$notnull_1").join(b?"typeof("+b+")!=='undefined' && ("+b+")!=null":"true").split("$1a").join(f).split("$1").join(e).split("$2").join(g||i.$2||"")+"__.push('"})+"');}return __;")}function n(c,b){c._wrap=j(c,true,a.isArray(b)?b:[q.test(b)?b:a(b).html()]).join("")}function h(a){return a?a.replace(/\\'/g,"'").replace(/\\\\/g,"\\"):null}function s(b){var a=document.createElement("div");a.appendChild(b.cloneNode(true));return a.innerHTML}function m(o){var n="_"+c,k,j,l={},e,p,h;for(e=0,p=o.length;e=0;h--)m(j[h]);m(k)}function m(j){var p,h=j,k,e,m;if(m=j.getAttribute(d)){while(h.parentNode&&(h=h.parentNode).nodeType===1&&!(p=h.getAttribute(d)));if(p!==m){h=h.parentNode?h.nodeType===11?0:h.getAttribute(d)||0:0;if(!(e=b[m])){e=f[m];e=g(e,b[h]||f[h]);e.key=++i;b[i]=e}c&&o(m)}j.removeAttribute(d)}else if(c&&(e=a.data(j,"tmplItem"))){o(e.key);b[e.key]=e;h=a.data(j.parentNode,"tmplItem");h=h?h.key:0}if(e){k=e;while(k&&k.key!=h){k.nodes.push(j);k=k.parent}delete e._ctnt;delete e._wrap;a.data(j,"tmplItem",e)}function o(a){a=a+n;e=l[a]=l[a]||g(e,b[e.parent.key+n]||e.parent)}}}function u(a,d,c,b){if(!a)return l.pop();l.push({_:a,tmpl:d,item:this,data:c,options:b})}function w(d,c,b){return a.tmpl(a.template(d),c,b,this)}function x(b,d){var c=b.options||{};c.wrapped=d;return a.tmpl(a.template(b.tmpl),b.data,c,b.item)}function v(d,c){var b=this._wrap;return a.map(a(a.isArray(b)?b.join(""):b).filter(d||"*"),function(a){return c?a.innerText||a.textContent:a.outerHTML||s(a)})}function t(){var b=this.nodes;a.tmpl(null,null,null,this).insertBefore(b[0]);a(b).remove()}})(jQuery); 11 | -------------------------------------------------------------------------------- /lib/client/03.helpers.js: -------------------------------------------------------------------------------- 1 | // Helpers 2 | // ------- 3 | // These prototype helpers affect ALL your client-side and shared code. They also exist server-side, so you can use them anywhere. 4 | // It is possible these helpers may conflict with an external third party library. Feel free to edit or delete this file if you don't want to use them. 5 | // You will always be able to obtain the latest version of this file by coping/merging it in from a new project. 6 | // Thanks to Addy Osmani for writing these and providing tests at https://github.com/addyosmani/socketstream-helpers 7 | 8 | /** 9 | .bind() support 10 | **/ 11 | 12 | if ( !Function.prototype.bind ) { 13 | Function.prototype.bind = function( obj ) { 14 | var slice = [].slice, 15 | args = slice.call(arguments, 1), 16 | self = this, 17 | nop = function () {}, 18 | bound = function () { 19 | return self.apply( this instanceof nop ? this : ( obj || {} ), 20 | args.concat( slice.call(arguments) ) ); 21 | }; 22 | 23 | nop.prototype = self.prototype; 24 | bound.prototype = new nop(); 25 | 26 | return bound; 27 | }; 28 | } 29 | 30 | /** 31 | Removes any duplicate entries from the current array 32 | **/ 33 | if ( !String.prototype.unique ) { 34 | String.prototype.unique = function(b){ 35 | var a = "", i, l = this.length,q=""; 36 | for( i=0; i length) { 82 | return this.slice(0, length - 3) + "..."; 83 | }else { 84 | return this; 85 | } 86 | }; 87 | } 88 | 89 | /** 90 | Truncates the current array to the supplied length 91 | **/ 92 | if ( !Array.prototype.truncate ) { 93 | Array.prototype.truncate = function(length){ 94 | return this.slice(0, length); 95 | } 96 | } 97 | 98 | /** 99 | Returns a random character from the current string 100 | **/ 101 | if ( !String.prototype.random ) { 102 | String.prototype.random = function( r ) { 103 | var i = 0, l = this.length; 104 | if( !r ) { r = this.length; } 105 | else if( r > 0 ) { r = r % l; } 106 | else { i = r; r = l + r % l; } 107 | return this[ Math.floor( r * Math.random() - i ) ]; 108 | }; 109 | } 110 | 111 | /** 112 | Returns a random element from the current array 113 | **/ 114 | if ( !Array.prototype.random ) { 115 | Array.prototype.random = function( r ) { 116 | var i = 0, l = this.length; 117 | if( !r ) { r = this.length; } 118 | else if( r > 0 ) { r = r % l; } 119 | else { i = r; r = l + r % l; } 120 | return this[ Math.floor( r * Math.random() - i ) ]; 121 | }; 122 | } 123 | 124 | /** 125 | Boolean check to find out if a supplied character is in the current string 126 | **/ 127 | 128 | if ( !String.prototype.include ) { 129 | String.prototype.include = function(value) { 130 | var i = this.length; 131 | while (i--) { 132 | if (this[i] === value) return true; 133 | } 134 | return false; 135 | }; 136 | } 137 | 138 | /** 139 | Boolean check to find out if a supplied element/string is in the current array 140 | **/ 141 | if ( !Array.prototype.include ) { 142 | Array.prototype.include = function(value) { 143 | var i = this.length; 144 | while (i--) { 145 | if (this[i] === value) return true; 146 | } 147 | return false; 148 | }; 149 | } 150 | 151 | /** 152 | Boolean check to find out if a supplied character is in the current string 153 | **/ 154 | 155 | if ( !String.prototype.contains ) { 156 | String.prototype.contains = String.prototype.include; 157 | } 158 | 159 | /** 160 | Boolean check to find out if a supplied character is in the current array 161 | **/ 162 | 163 | if ( !Array.prototype.contains ) { 164 | Array.prototype.contains = Array.prototype.include; 165 | } 166 | 167 | /** 168 | Boolean check to find out if an array contains any elements 169 | **/ 170 | if ( !Array.prototype.any ) { 171 | Array.prototype.any = function(){ 172 | return !(this && this.constructor==Array && this.length==0); 173 | }; 174 | } 175 | 176 | /** 177 | Sanitize content containing URLs or mailto/email references 178 | **/ 179 | if ( !String.prototype.sanitize ) { 180 | String.prototype.sanitize = function(){ 181 | return this.replace(/(([fh]+t+p+s?\:\/)+([^"'\s]+))/gi,"$1<\/a>"). 182 | replace(/([a-z0-9\-\.]+\@[a-z0-9\-]+([^"'\s]+))/gi,"$1<\/a>"); 183 | }; 184 | } -------------------------------------------------------------------------------- /lib/client/07.bootstrap-dropdown.js: -------------------------------------------------------------------------------- 1 | /* ============================================================ 2 | * bootstrap-dropdown.js v1.4.0 3 | * http://twitter.github.com/bootstrap/javascript.html#dropdown 4 | * ============================================================ 5 | * Copyright 2011 Twitter, Inc. 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * ============================================================ */ 19 | 20 | 21 | !function( $ ){ 22 | 23 | "use strict" 24 | 25 | /* DROPDOWN PLUGIN DEFINITION 26 | * ========================== */ 27 | 28 | $.fn.dropdown = function ( selector ) { 29 | return this.each(function () { 30 | $(this).delegate(selector || d, 'click', function (e) { 31 | var li = $(this).parent('li') 32 | , isActive = li.hasClass('open') 33 | 34 | clearMenus() 35 | !isActive && li.toggleClass('open') 36 | return false 37 | }) 38 | }) 39 | } 40 | 41 | /* APPLY TO STANDARD DROPDOWN ELEMENTS 42 | * =================================== */ 43 | 44 | var d = 'a.menu, .dropdown-toggle' 45 | 46 | function clearMenus() { 47 | $(d).parent('li').removeClass('open') 48 | } 49 | 50 | $(function () { 51 | $('html').bind("click", clearMenus) 52 | $('body').dropdown( '[data-dropdown] a.menu, [data-dropdown] .dropdown-toggle' ) 53 | }) 54 | 55 | }( window.jQuery || window.ender ); 56 | -------------------------------------------------------------------------------- /lib/client/08.bootstrap-modal.js: -------------------------------------------------------------------------------- 1 | /* ========================================================= 2 | * bootstrap-modal.js v1.4.0 3 | * http://twitter.github.com/bootstrap/javascript.html#modal 4 | * ========================================================= 5 | * Copyright 2011 Twitter, Inc. 6 | * 7 | * Licensed under the Apache License, Version 2.0 (the "License"); 8 | * you may not use this file except in compliance with the License. 9 | * You may obtain a copy of the License at 10 | * 11 | * http://www.apache.org/licenses/LICENSE-2.0 12 | * 13 | * Unless required by applicable law or agreed to in writing, software 14 | * distributed under the License is distributed on an "AS IS" BASIS, 15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | * See the License for the specific language governing permissions and 17 | * limitations under the License. 18 | * ========================================================= */ 19 | 20 | 21 | !function( $ ){ 22 | 23 | "use strict" 24 | 25 | /* CSS TRANSITION SUPPORT (https://gist.github.com/373874) 26 | * ======================================================= */ 27 | 28 | var transitionEnd 29 | 30 | $(document).ready(function () { 31 | 32 | $.support.transition = (function () { 33 | var thisBody = document.body || document.documentElement 34 | , thisStyle = thisBody.style 35 | , support = thisStyle.transition !== undefined || thisStyle.WebkitTransition !== undefined || thisStyle.MozTransition !== undefined || thisStyle.MsTransition !== undefined || thisStyle.OTransition !== undefined 36 | return support 37 | })() 38 | 39 | // set CSS transition event type 40 | if ( $.support.transition ) { 41 | transitionEnd = "TransitionEnd" 42 | if ( $.browser.webkit ) { 43 | transitionEnd = "webkitTransitionEnd" 44 | } else if ( $.browser.mozilla ) { 45 | transitionEnd = "transitionend" 46 | } else if ( $.browser.opera ) { 47 | transitionEnd = "oTransitionEnd" 48 | } 49 | } 50 | 51 | }) 52 | 53 | 54 | /* MODAL PUBLIC CLASS DEFINITION 55 | * ============================= */ 56 | 57 | var Modal = function ( content, options ) { 58 | this.settings = $.extend({}, $.fn.modal.defaults, options) 59 | this.$element = $(content) 60 | .delegate('.close', 'click.modal', $.proxy(this.hide, this)) 61 | 62 | if ( this.settings.show ) { 63 | this.show() 64 | } 65 | 66 | return this 67 | } 68 | 69 | Modal.prototype = { 70 | 71 | toggle: function () { 72 | return this[!this.isShown ? 'show' : 'hide']() 73 | } 74 | 75 | , show: function () { 76 | var that = this 77 | this.isShown = true 78 | this.$element.trigger('show') 79 | 80 | escape.call(this) 81 | backdrop.call(this, function () { 82 | var transition = $.support.transition && that.$element.hasClass('fade') 83 | 84 | that.$element 85 | .appendTo(document.body) 86 | .show() 87 | 88 | if (transition) { 89 | that.$element[0].offsetWidth // force reflow 90 | } 91 | 92 | that.$element.addClass('in') 93 | 94 | transition ? 95 | that.$element.one(transitionEnd, function () { that.$element.trigger('shown') }) : 96 | that.$element.trigger('shown') 97 | 98 | }) 99 | 100 | return this 101 | } 102 | 103 | , hide: function (e) { 104 | e && e.preventDefault() 105 | 106 | if ( !this.isShown ) { 107 | return this 108 | } 109 | 110 | var that = this 111 | this.isShown = false 112 | 113 | escape.call(this) 114 | 115 | this.$element 116 | .trigger('hide') 117 | .removeClass('in') 118 | 119 | $.support.transition && this.$element.hasClass('fade') ? 120 | hideWithTransition.call(this) : 121 | hideModal.call(this) 122 | 123 | return this 124 | } 125 | 126 | } 127 | 128 | 129 | /* MODAL PRIVATE METHODS 130 | * ===================== */ 131 | 132 | function hideWithTransition() { 133 | // firefox drops transitionEnd events :{o 134 | var that = this 135 | , timeout = setTimeout(function () { 136 | that.$element.unbind(transitionEnd) 137 | hideModal.call(that) 138 | }, 500) 139 | 140 | this.$element.one(transitionEnd, function () { 141 | clearTimeout(timeout) 142 | hideModal.call(that) 143 | }) 144 | } 145 | 146 | function hideModal (that) { 147 | this.$element 148 | .hide() 149 | .trigger('hidden') 150 | 151 | backdrop.call(this) 152 | } 153 | 154 | function backdrop ( callback ) { 155 | var that = this 156 | , animate = this.$element.hasClass('fade') ? 'fade' : '' 157 | if ( this.isShown && this.settings.backdrop ) { 158 | var doAnimate = $.support.transition && animate 159 | 160 | this.$backdrop = $('