diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee index f51d1ed41e..b2257099b4 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee @@ -73,16 +73,24 @@ define [ _scrollTimeout = null , 200 + @_resetCutState() + onCut = () => @onCut() + onPaste = () => @onPaste() + bindToAce = () => @editor.on "changeSelection", onChangeSelection @editor.on "change", onChangeSelection # Selection also moves with updates elsewhere in the document @editor.on "changeSession", onChangeSession + @editor.on "cut", onCut + @editor.on "paste", onPaste @editor.renderer.on "resize", onResize unbindFromAce = () => @editor.off "changeSelection", onChangeSelection @editor.off "change", onChangeSelection @editor.off "changeSession", onChangeSession + @editor.off "cut", onCut + @editor.off "paste", onPaste @editor.renderer.off "resize", onResize @$scope.$watch "trackChangesEnabled", (enabled) => @@ -244,6 +252,50 @@ define [ @_onCommentAdded(comment) @broadcastChange() + _resetCutState: () -> + @_cutState = { + text: null + comments: [] + docId: null + } + + onCut: () -> + @_resetCutState() + selection = @editor.getSelectionRange() + selection_start = @_aceRangeToShareJs(selection.start) + selection_end = @_aceRangeToShareJs(selection.end) + @_cutState.text = @editor.getSelectedText() + @_cutState.docId = @$scope.docId + for comment in @rangesTracker.comments + comment_start = comment.op.p + comment_end = comment_start + comment.op.c.length + if selection_start <= comment_start and comment_end <= selection_end + @_cutState.comments.push { + offset: comment.op.p - selection_start + text: comment.op.c + comment: comment + } + + onPaste: () => + @editor.once "change", (change) => + return if change.action != "insert" + pasted_text = change.lines.join("\n") + paste_offset = @_aceRangeToShareJs(change.start) + console.log "PASTE", pasted_text, paste_offset + # We have to wait until the change has been processed by the range tracker, + # since if we move the ops into place beforehand, they will be moved again + # when the changes are processed by the range tracker. This ranges:dirty + # event is fired after the doc has applied the changes to the range tracker. + @$scope.sharejsDoc.on "ranges:dirty.paste", () => + @$scope.sharejsDoc.off "ranges:dirty.paste" # Doc event emitter uses namespaced events + if pasted_text == @_cutState.text and @$scope.docId == @_cutState.docId + for {comment, offset, text} in @_cutState.comments + op = { c: text, p: paste_offset + offset, t: comment.id } + @$scope.sharejsDoc.submitOp op # Resubmitting an existing comment op (by thread id) will move it + @_resetCutState() + # Check that comments still match text. Will throw error if not. + @rangesTracker.validate(@editor.getValue()) + checkMapping: () -> # TODO: reintroduce this check session = @editor.getSession() diff --git a/services/web/public/coffee/ide/review-panel/RangesTracker.coffee b/services/web/public/coffee/ide/review-panel/RangesTracker.coffee index a9c43e9816..14193f628d 100644 --- a/services/web/public/coffee/ide/review-panel/RangesTracker.coffee +++ b/services/web/public/coffee/ide/review-panel/RangesTracker.coffee @@ -1,3 +1,6 @@ +# This file is shared between document-updater and web, so that the server and client share +# an identical track changes implementation. Do not edit it directly in web or document-updater, +# instead edit it at https://github.com/sharelatex/ranges-tracker, where it has a suite of tests load = () -> class RangesTracker # The purpose of this class is to track a set of inserts and deletes to a document, like @@ -78,6 +81,13 @@ load = () -> @comments = @comments.filter (c) -> c.id != comment_id @_markAsDirty comment, "comment", "removed" + moveCommentId: (comment_id, position, text) -> + for comment in @comments + if comment.id == comment_id + comment.op.p = position + comment.op.c = text + @_markAsDirty comment, "comment", "moved" + getChange: (change_id) -> change = null for c in @changes @@ -90,6 +100,18 @@ load = () -> change = @getChange(change_id) return if !change? @_removeChange(change) + + validate: (text) -> + for change in @changes + if change.op.i? + content = text.slice(change.op.p, change.op.p + change.op.i.length) + if content != change.op.i + throw new Error("Change (#{JSON.stringify(change)}) doesn't match text (#{JSON.stringify(content)})") + for comment in @comments + content = text.slice(comment.op.p, comment.op.p + comment.op.c.length) + if content != comment.op.c + throw new Error("Comment (#{JSON.stringify(comment)}) doesn't match text (#{JSON.stringify(content)})") + return true applyOp: (op, metadata = {}) -> metadata.ts ?= new Date() @@ -110,17 +132,21 @@ load = () -> @applyOp(op, metadata) addComment: (op, metadata) -> - # TODO: Don't allow overlapping comments? - @comments.push comment = { - id: op.t or @newId() - op: # Copy because we'll modify in place - c: op.c - p: op.p - t: op.t - metadata - } - @_markAsDirty comment, "comment", "added" - return comment + existing = @getComment(op.t) + if existing? + @moveCommentId(op.t, op.p, op.c) + return existing + else + @comments.push comment = { + id: op.t or @newId() + op: # Copy because we'll modify in place + c: op.c + p: op.p + t: op.t + metadata + } + @_markAsDirty comment, "comment", "added" + return comment applyInsertToComments: (op) -> for comment in @comments