diff --git a/customize.dist/ckeditor-contents.css b/customize.dist/ckeditor-contents.css index 378d61793..75d054230 100644 --- a/customize.dist/ckeditor-contents.css +++ b/customize.dist/ckeditor-contents.css @@ -149,3 +149,38 @@ a > img { border: none; outline: 1px solid #0782C1; } + +.cp-cursor-position { + cursor: default; + background-color: red; + background-clip: padding-box; + padding: 0 1px; + border: 2px solid red; + border-right-color: transparent !important; + border-left-color: transparent !important; +} +.cp-cursor-position[data-type="start"] { + border-left: none; + border-right-width: 4px; +} +.cp-cursor-position[data-type="end"] { + border-right: none; + border-left-width: 4px; +} +.cp-cursor-avatar { + display: flex; + align-items: center; +} +.cp-cursor-avatar media-tag { + min-height: 32px; + max-height: 32px; + min-width: 32px; + max-width: 32px; + margin-right: 10px; +} +.cp-cursor-avatar media-tag img { + border-radius: 4px; + max-height: 100%; + max-width: 100%; +} + diff --git a/www/common/cursor.js b/www/common/cursor.js index fffbe1db6..01f71d8d5 100644 --- a/www/common/cursor.js +++ b/www/common/cursor.js @@ -173,7 +173,12 @@ define([ // their length. var newOffset = offset; for (var i = 0; i < el.childNodes.length; i++) { + try { newOffset -= (getTextNodeValue(el.childNodes[i]) || el.childNodes[i].outerHTML).length; + } catch (e) { + console.log(el); + console.log(el.childNodes[i]); + } if (newOffset <= 0) { return getFinalRange(el.childNodes[i], offset); } @@ -216,6 +221,19 @@ define([ return range; }; + cursor.getNewOffset = function (ops) { + return { + selectionStart: offsetTransformRange(offsetRange.start, ops), + selectionEnd: offsetTransformRange(offsetRange.end, ops) + }; + }; + cursor.getNewRange = function (data, ops) { + offsetRange.start = offsetTransformRange(data.start, ops); + offsetRange.end = offsetTransformRange(data.end, ops); + var range = getRangeFromOffset(inner); + return range; + }; + // Restore the cursor position after applying the changes. cursor.restoreOffset = function (ops) { try { diff --git a/www/common/sframe-app-framework.js b/www/common/sframe-app-framework.js index c16805b2b..8bba4d111 100644 --- a/www/common/sframe-app-framework.js +++ b/www/common/sframe-app-framework.js @@ -332,7 +332,11 @@ define([ if (!readOnly && cursorGetter) { common.openCursorChannel(onLocal); cursor = common.createCursor(); - cursor.onCursorUpdate(evCursorUpdate.fire); + cursor.onCursorUpdate(function (data) { + var newContentStr = cpNfInner.chainpad.getUserDoc(); + var hjson = normalize(JSON.parse(newContentStr)); + evCursorUpdate.fire(data, hjson); + }); } UI.removeLoadingScreen(emitResize); @@ -654,7 +658,9 @@ define([ onCursorUpdate: evCursorUpdate.reg, updateCursor: function () { if (cursor && cursorGetter) { - cursor.updateCursor(cursorGetter()); + var newContentStr = cpNfInner.chainpad.getUserDoc(); + var data = normalize(JSON.parse(newContentStr)); + cursor.updateCursor(cursorGetter(data)); } }, diff --git a/www/pad/cursor.js b/www/pad/cursor.js new file mode 100644 index 000000000..ea3d7760c --- /dev/null +++ b/www/pad/cursor.js @@ -0,0 +1,154 @@ +define([ + 'jquery', + '/common/common-ui-elements.js', + '/common/common-interface.js', + '/bower_components/chainpad/chainpad.dist.js', +], function ($, UIElements, UI, ChainPad) { + var Cursor = {}; + + Cursor.isCursor = function (el) { + return typeof (el.getAttribute) === "function" && + el.getAttribute('class') && + /cp-cursor-position/.test(el.getAttribute('class')); + }; + + Cursor.preDiffApply = function (info) { + if (info.node && info.node.tagName === 'SPAN' && + info.node.getAttribute('class') && + /cp-cursor-position/.test(info.node.getAttribute('class'))) { + if (info.diff.action === 'removeElement') { + console.error('PREVENTING REMOVAL OF CURSOR', info.node); + return true; + } + } + }; + + Cursor.create = function (inner, hjsonToDom, cursorModule) { + var exp = {}; + + var cursors = {}; + + var makeTippy = function (cursor) { + /*var html = ''; + if (cursor.avatar && UIElements.getAvatar(cursor.avatar)) { + html += UIElements.getAvatar(cursor.avatar); + } + html += cursor.name + ''; + return html;*/ + return cursor.name; + }; + + var makeCursor = function (id, cursor) { + if (cursors[id]) { + cursors[id].el.remove(); + cursors[id].elstart.remove(); + cursors[id].elend.remove(); + } + cursors[id] = { + el: $('', { + 'id': id, + 'data-type': '', + title: makeTippy(cursor), + 'class': 'cp-cursor-position' + })[0], + elstart: $('', { + 'id': id, + 'data-type': 'start', + title: makeTippy(cursor), + 'class': 'cp-cursor-position' + })[0], + elend: $('', { + 'id': id, + 'data-type': 'end', + title: makeTippy(cursor), + 'class': 'cp-cursor-position' + })[0], + }; + return cursors[id]; + }; + var deleteCursor = function (id) { + if (!cursors[id]) { return; } + cursors[id].el.remove(); + cursors[id].elstart.remove(); + cursors[id].elend.remove(); + delete cursors[id]; + }; + + + var addCursorAtRange = function (cursorEl, r, cursor, type) { + var pos = type || 'start'; + var p = r[pos].el.parentNode; + var el = cursorEl['el'+type]; + if (cursor.color) { + $(el).css('border-color', cursor.color); + $(el).css('background-color', cursor.color); + } + if (r[pos].offset === 0) { + if (r[pos].el.nodeType === r[pos].el.TEXT_NODE) { + // Text node, insert at the beginning + p.insertBefore(el, p.childNodes[0] || null); + } else { + // Other node, insert as first child + r[pos].el.insertBefore(el, r[pos].el.childNodes[0] || null); + } + } else { + if (r[pos].el.nodeType !== r[pos].el.TEXT_NODE) { return; } + // Text node, we have to split... + var newNode = r[pos].el.splitText(r[pos].offset); + p.insertBefore(el, newNode); + } + }; + + exp.removeCursors = function () { + for (var id in cursors) { + deleteCursor(id); + } + }; + + exp.cursorGetter = function (hjson) { + cursorModule.offsetUpdate(); + var userDocStateDom = hjsonToDom(hjson); + var ops = ChainPad.Diff.diff(inner.outerHTML, userDocStateDom.outerHTML); + return cursorModule.getNewOffset(ops); + }; + + exp.onCursorUpdate = function (data, hjson) { + if (data.leave) { + if (data.id.length === 32) { + Object.keys(cursors).forEach(function (id) { + if (id.indexOf(data.id) === 0) { deleteCursor(id); } + }); + } + deleteCursor(data.id); + return; + } + var id = data.id; + var cursorObj = data.cursor; + + if (!cursorObj.selectionStart) { return; } + + // 1. Transform the cursor to get the offset relative to our doc + // 2. Turn it into a range + var userDocStateDom = hjsonToDom(hjson); + var ops = ChainPad.Diff.diff(userDocStateDom.outerHTML, inner.outerHTML); + var r = cursorModule.getNewRange({ + start: cursorObj.selectionStart, + end: cursorObj.selectionEnd + }, ops); + var cursorEl = makeCursor(id, cursorObj); + if (r.start.el === r.end.el && r.start.offset === r.end.offset) { + // Cursor + addCursorAtRange(cursorEl, r, cursorObj, ''); + } else { + // Selection + addCursorAtRange(cursorEl, r, cursorObj, 'end'); + addCursorAtRange(cursorEl, r, cursorObj, 'start'); + } + inner.normalize(); + }; + + return exp; + }; + + return Cursor; +}); diff --git a/www/pad/inner.js b/www/pad/inner.js index 9442eea7a..c1c952683 100644 --- a/www/pad/inner.js +++ b/www/pad/inner.js @@ -26,6 +26,7 @@ define([ '/customize/messages.js', '/pad/links.js', '/pad/export.js', + '/pad/cursor.js', '/bower_components/nthen/index.js', '/common/media-tag.js', '/api/config', @@ -51,6 +52,7 @@ define([ Messages, Links, Exporter, + Cursors, nThen, MediaTag, ApiConfig, @@ -109,8 +111,10 @@ define([ el.getAttribute('class').split(' ').indexOf('non-realtime') !== -1); }; + var isCursor = Cursors.isCursor; + var shouldSerialize = function (el) { - return isNotMagicLine(el) && !isWidget(el); + return isNotMagicLine(el) && !isWidget(el) && !isCursor(el); }; // MEDIATAG: Filter attributes in the serialized elements @@ -217,6 +221,10 @@ define([ } } + // Other users cursor + if (Cursors.preDiffApply(info)) { + return true; + } // MEDIATAG // Never modify widget ids @@ -454,8 +462,12 @@ define([ var inner = window.inner = documentBody; + // My cursor var cursor = module.cursor = Cursor(inner); + // Display other users cursor + var cursors = Cursors.create(inner, hjsonToDom, cursor); + var openLink = function (e) { var el = e.currentTarget; if (!el || el.nodeName !== 'A') { return; } @@ -496,10 +508,17 @@ define([ var DD = new DiffDom(mkDiffOptions(cursor, framework.isReadOnly())); + var cursorStopped = false; + var updateCursor = function () { + if (cursorStopped) { return; } + framework.updateCursor(); + }; + // apply patches, and try not to lose the cursor in the process! framework.onContentUpdate(function (hjson) { if (!Array.isArray(hjson)) { throw new Error(Messages.typeError); } var userDocStateDom = hjsonToDom(hjson); + cursorStopped = true; userDocStateDom.setAttribute("contenteditable", inner.getAttribute('contenteditable')); @@ -527,6 +546,9 @@ define([ var ops = ChainPad.Diff.diff(oldText, newText); cursor.restoreOffset(ops); + cursorStopped = false; + updateCursor(); + // MEDIATAG: Migrate old mediatags to the widget system $(inner).find('media-tag:not(.cke_widget_element)').each(function (i, el) { var element = new window.CKEDITOR.dom.element(el); @@ -561,9 +583,15 @@ define([ $(el).remove(); }); + // We have to remove the cursors before getting the content because they split + // the text nodes and OT/ChainPad would freak out + cursors.removeCursors(); + displayMediaTags(framework, inner, mediaTagMap); inner.normalize(); - return Hyperjson.fromDOM(inner, shouldSerialize, hjsonFilters); + var hjson = Hyperjson.fromDOM(inner, shouldSerialize, hjsonFilters); + + return hjson; }); $bar.find('#cke_1_toolbar_collapser').hide(); @@ -694,6 +722,12 @@ define([ ]; }); + /* Display the cursor of other users and send our cursor */ + framework.setCursorGetter(cursors.cursorGetter); + framework.onCursorUpdate(cursors.onCursorUpdate); + inner.addEventListener('click', updateCursor); + inner.addEventListener('keyup', updateCursor); + /* hitting enter makes a new line, but places the cursor inside of the
instead of the

. This makes it such that you