diff --git a/README.md b/README.md index 7b552e8..53bbc19 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,47 @@ # Share-CodeMirror [![Build Status](https://secure.travis-ci.org/share/share-codemirror.png)](http://travis-ci.org/share/share-codemirror) [![Dependencies](https://david-dm.org/share/share-codemirror.png)](https://david-dm.org/share/share-codemirror) [![devDependency Status](https://david-dm.org/share/share-codemirror/dev-status.png)](https://david-dm.org/share/share-codemirror#info=devDependencies) CodeMirror bindings for ShareJS >= 0.7.x. +## Dependencies + +You need [lodash](http://lodash.com/) loaded before this library. + ## Usage ```javascript var cm = CodeMirror.fromTextArea(elem); -shareDoc.attachCodeMirror(cm); + +var ctx = shareDoc.createContext(); +shareDoc.attachCodeMirror(cm, ctx); +shareDoc.attachCodeMirrorCursor(cm, ctx); ``` -That's it. You now have 2-way sync between your ShareJS and CodeMirror. +That's it. You now have 2-way sync between your ShareJS and CodeMirror. + +### Configuration + +The `attachCodeMirrorCursor` takes an optional 3rd `options` argument where the +following options may be set: + +* `inactiveTimeout` - how long the "name" part of a cursor is visible after inactivity +* `color` - the color of the cursor +* `selectionColor` - the color of text selection +* `textColor` - the color of the "name" text + +These attributes can also be set on a per-user basis with presence properties: + +```javascript +shareDoc.setPresenceProperty("name", name); +shareDoc.setPresenceProperty("color", color); +shareDoc.setPresenceProperty("selectionColor", selectionColor); +shareDoc.setPresenceProperty("textColor", textColor); +shareDoc.setPresenceProperty("inactiveTimeout", 10000); +``` + +Share-Codemirror will provide default values for all of these properties if you +don't set them explicitly. You may want to use a color library such as +[TinyColor](http://bgrins.github.io/TinyColor/) +to manipulate colors, for example setting `selectionColor` a bit brighter than +`color`, or finding a legible `textColor` based on `color`. ## Install with Bower @@ -22,7 +55,7 @@ bower install share-codemirror npm install share-codemirror ``` -On Node.js you can mount the `scriptsDir` (where `share-codemirror.js` lives) as a static resource +On Node.js you can mount the `scriptsDir` (where `share-codemirror.js` lives) as a static resource in your web server: ```javascript @@ -41,6 +74,7 @@ In the HTML: ``` npm install +npm test node examples/server.js # in a couple of browsers... open http://localhost:7007 @@ -78,4 +112,3 @@ git push --tags ``` There is no `bower publish` - the existance of a git tag is enough. - diff --git a/examples/index.html b/examples/index.html index 940e116..dadb3a7 100644 --- a/examples/index.html +++ b/examples/index.html @@ -1,66 +1,89 @@ - - - - - + + + + + + + + + + - + +
+ +
+
diff --git a/examples/server.js b/examples/server.js index c37edc5..f9bfdc4 100644 --- a/examples/server.js +++ b/examples/server.js @@ -10,6 +10,8 @@ var webserver = connect( connect["static"](__dirname), connect["static"](shareCodeMirror.scriptsDir), connect["static"](__dirname + '/../node_modules/codemirror/lib'), + connect["static"](__dirname + '/../node_modules/tinycolor2/dist'), + connect["static"](__dirname + '/../node_modules/lodash/dist'), connect["static"](sharejs.scriptsDir) ); @@ -19,7 +21,12 @@ var backend = livedb.client(livedbMongo('localhost:27017/test?auto_reconnect', { var share = sharejs.server.createClient({backend: backend}); -webserver.use(browserChannel({webserver: webserver}, function (client) { +var clientsById = {}; + +webserver.use(browserChannel({webserver: webserver, sessionTimeoutInterval: 5000}, function (client) { + clientsById[client.id] = client; + //client.send({_type: 'connectionId', connectionId: client.id}); + var stream = new Duplex({objectMode: true}); stream._write = function (chunk, encoding, callback) { if (client.state !== 'closed') { @@ -35,12 +42,15 @@ webserver.use(browserChannel({webserver: webserver}, function (client) { stream.push(data); }); stream.on('error', function (msg) { + console.log('ERROR', msg, client.id); client.stop(); }); client.on('close', function (reason) { + console.log('CLOSE', reason, client.id); stream.emit('close'); stream.emit('end'); stream.end(); + delete clientsById[client.id]; }); return share.listen(stream); })); diff --git a/package.json b/package.json index 564d64b..3efc38b 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "CodeMirror bindings for ShareJS", "main": "share-codemirror.js", "scripts": { - "pretest": "cd node_modules/share && npm install", + "pretest": "cd node_modules/share && UGLIFY=../../node_modules/.bin/uglifyjs make webclient/share.uncompressed.js", "test": "node_modules/.bin/mocha" }, "repository": { @@ -21,14 +21,17 @@ "url": "https://github.com/share/share-codemirror/issues" }, "devDependencies": { - "share": "v0.7.0-alpha9", - "codemirror": "~4.0.3", + "browserchannel": "~1.2.0", + "codemirror": "~4.2.0", "connect": "~2.11.0", - "browserchannel": "~1.0.8", - "livedb": "~0.2.6", - "livedb-mongo": "~0.2.5", - "mocha": "~1.14.0", - "jsdom": "~0.8.8", - "istanbul": "~0.1.44" + "istanbul": "~0.2.10", + "jsdom": "~0.10.6", + "livedb": "git://github.com/share/livedb.git#f03086cdf51ffc80c725f105c7236c2997d74eac", + "livedb-mongo": "~0.3.2", + "lodash": "~2.4.1", + "mocha": "~1.20.0", + "share": "git://github.com/share/ShareJS.git#94dd9e0659c9b6953ba888f5dde8ce223a71f2ec", + "tinycolor2": "^0.10.0", + "uglify-js": "~2.4.13" } } diff --git a/share-codemirror-cursor.js b/share-codemirror-cursor.js new file mode 100644 index 0000000..8b6a223 --- /dev/null +++ b/share-codemirror-cursor.js @@ -0,0 +1,182 @@ +(function () { + 'use strict'; + + function shareCodeMirrorCursor(cm, ctx, options) { + options = options || {}; + options.selectionColor = options.selectionColor || 'yellow'; + options.color = options.color || '#dddddd'; + options.textColor = options.textColor || 'black'; + options.inactiveTimeout = options.inactiveTimeout || 5000; + + cm.on('cursorActivity', function (editor) { + if (ctx.suppress) return; + + var start = editor.indexFromPos(editor.getCursor('start')); + var end = editor.indexFromPos(editor.getCursor('end')); + ctx.setSelection([start, end]); + }); + + var cursorsBySessionId = {}; + + ctx.onPresence = function (presence) { + var sessionIds = Object.keys(presence); + makeUserStyles(presence); + sessionIds.forEach(function (sessionId) { + var session = presence[sessionId]; + displayCursor(sessionId, session); + }); + var cursorIds = Object.keys(cursorsBySessionId); + cursorIds.forEach(function (cid) { + if (sessionIds.indexOf(cid) < 0) { + cursorsBySessionId[cid].remove(); + delete cursorsBySessionId[cid]; + } + }) + }; + + function makeUserStyles(sessions) { + var ids = Object.keys(sessions); + var headStyle = document.getElementById("user-styles"); + if (!headStyle) { + var head = document.getElementsByTagName("head")[0]; + headStyle = document.createElement("style"); + headStyle.setAttribute("id", "user-styles"); + head.appendChild(headStyle); + } + var style = ""; + for (var i = 0; i < ids.length; i++) { + var session = sessions[ids[i]]; + var selectionColor = session.selectionColor || session.color || options.selectionColor; + style += ".user-" + ids[i] + " { background: " + selectionColor + "; }"; + } + headStyle.innerHTML = style; + } + + function displayCursor(sessionId, session) { + // we make a cursor widget to display where the other user's cursor is + var selection = session._selection; + if (!selection) return; + if (typeof selection == "number") selection = [selection, selection]; + var from = cm.posFromIndex(selection[0]); + var to = cm.posFromIndex(selection[1]); + + var cursor = cursorsBySessionId[sessionId]; + if (cursor === undefined) { + cursor = new Cursor(cm, sessionId); + cursorsBySessionId[sessionId] = cursor; + } + cursor.update(session, from, to); + } + + var ownerFactor = 0.8; // Relative size of owner font + + function Cursor(cm, sessionId) { + // TODO: Make similar to this: + // https://github.com/quilljs/quill/blob/develop/src/modules/multi-cursor.coffee + // http://quilljs.com/docs/modules/multi-cursors/ + + // The parent element of the cursor + var widget = document.createElement('div'); + widget.style.position = 'absolute'; + widget.style.zIndex = 1000; + + // The caret + var caret = document.createElement('pre'); + caret.style.borderLeftWidth = '2px'; + caret.style.borderLeftStyle = 'solid'; + caret.style.height = cm.defaultTextHeight() + 'px'; + caret.style.marginTop = '-' + cm.defaultTextHeight() + 'px'; + caret.innerHTML = ' '; + widget.appendChild(caret); + + // The name + var owner = document.createElement('div'); + owner.style.height = cm.defaultTextHeight() * ownerFactor + 'px'; + owner.style['font-size'] = cm.defaultTextHeight() * ownerFactor + 'px'; + widget.appendChild(owner); + + var dot = document.createElement('pre'); + var dotFactor = 0.5; // Relative size of owner font + dot.style.position = 'relative'; + dot.style.borderLeftWidth = '6px'; + dot.style.borderLeftStyle = 'solid'; + dot.style.height = cm.defaultTextHeight() * dotFactor + 'px'; + dot.style.marginTop = '-' + ((1 + dotFactor) * cm.defaultTextHeight()) + 'px'; + dot.style.marginLeft = '-2px'; + widget.appendChild(dot); + + var lastSession; + var marker; + var inactiveTimer; + + this.update = function (session, from, to) { + if(_.isEqual(session, lastSession)) { + return; + } + lastSession = _.cloneDeep(session); + + caret.style.borderLeftColor = session.color || options.color; + dot.style.borderLeftColor = session.color || options.color; + owner.style.background = session.color || options.color; + owner.style.color = session.textColor || options.textColor; + owner.innerHTML = session.name || sessionId; + + if(to.line === 0) { + // Display the owner text below the caret. + // This is to prevent owner from being invisible (outside editor) + owner.style.marginTop = '0'; + } else { + // Display the owner text above the caret + owner.style.marginTop = '-' + ((1 + ownerFactor) * cm.defaultTextHeight()) + 'px'; + } + + // We mark up the range of text the other user has highlighted + if (marker) { + marker.clear(); + } + marker = cm.markText(from, to, { className: "user-" + sessionId }); + + cm.addWidget(to, widget); + + if(inactiveTimer) { + clearTimeout(inactiveTimer); + } + dot.style.display = 'none'; + owner.style.display = 'block'; + var inactiveTimeout = session.inactiveTimeout || options.inactiveTimeout; + inactiveTimer = setTimeout(function() { + dot.style.display = 'block'; + owner.style.display = 'none'; + }, inactiveTimeout); + }; + + this.remove = function () { + widget.parentElement.removeChild(widget); + if (marker) { + marker.clear(); + } + } + } + } + + // Exporting + if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { + // Node.js + module.exports = shareCodeMirrorCursor; + module.exports.scriptsDir = __dirname; + } else { + if (typeof define === 'function' && define.amd) { + // AMD + define([], function () { + return shareCodeMirrorCursor; + }); + } else { + // Browser, no AMD + window.sharejs.Doc.prototype.attachCodeMirrorCursor = function (cm, ctx, options) { + if (!ctx) ctx = this.createContext(); + shareCodeMirrorCursor(cm, ctx, options); + }; + } + } + +})(); diff --git a/share-codemirror.js b/share-codemirror.js index 9f66c7c..57a81ca 100644 --- a/share-codemirror.js +++ b/share-codemirror.js @@ -8,7 +8,6 @@ function shareCodeMirror(cm, ctx) { if (!ctx.provides.text) throw new Error('Cannot attach to non-text document'); - var suppress = false; var text = ctx.get() || ''; // Due to a bug in share - get() returns undefined for empty docs. cm.setValue(text); check(); @@ -16,26 +15,28 @@ // *** remote -> local changes ctx.onInsert = function (pos, text) { - suppress = true; + ctx.suppress = true; cm.replaceRange(text, cm.posFromIndex(pos)); - suppress = false; + ctx.suppress = false; check(); }; ctx.onRemove = function (pos, length) { - suppress = true; + ctx.suppress = true; var from = cm.posFromIndex(pos); var to = cm.posFromIndex(pos + length); cm.replaceRange('', from, to); - suppress = false; + ctx.suppress = false; check(); }; // *** local -> remote changes cm.on('change', function (cm, change) { - if (suppress) return; + if (ctx.suppress) return; + ctx.suppress = true; applyToShareJS(cm, change); + ctx.suppress = false; check(); }); @@ -84,7 +85,9 @@ console.error("cm: " + cmText); console.error("ot: " + otText); // Replace the editor text with the ctx snapshot. + ctx.suppress = true; cm.setValue(ctx.get() || ''); + ctx.suppress = false; } }, 0); }