diff --git a/services/web/public/coffee/ide/editor/EditorManager.coffee b/services/web/public/coffee/ide/editor/EditorManager.coffee index 72bbe8509a..e3cabf8e98 100644 --- a/services/web/public/coffee/ide/editor/EditorManager.coffee +++ b/services/web/public/coffee/ide/editor/EditorManager.coffee @@ -1,5 +1,6 @@ define [ "ide/editor/Document" + "ide/editor/components/spellMenu" "ide/editor/directives/aceEditor" "ide/editor/directives/toggleSwitch" "ide/editor/controllers/SavingNotificationController" diff --git a/services/web/public/coffee/ide/editor/components/spellMenu.coffee b/services/web/public/coffee/ide/editor/components/spellMenu.coffee new file mode 100644 index 0000000000..ff3462e03e --- /dev/null +++ b/services/web/public/coffee/ide/editor/components/spellMenu.coffee @@ -0,0 +1,34 @@ +define ["base"], (App) -> + App.component "spellMenu", { + bindings: { + open: "<" + top: "<" + left: "<" + highlight: "<" + replaceWord: "&" + learnWord: "&" + } + template: """ + + """ + } \ No newline at end of file diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor.coffee index ef3c5b20f8..617b41b845 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor.coffee @@ -7,6 +7,7 @@ define [ "ide/editor/directives/aceEditor/undo/UndoManager" "ide/editor/directives/aceEditor/auto-complete/AutoCompleteManager" "ide/editor/directives/aceEditor/spell-check/SpellCheckManager" + "ide/editor/directives/aceEditor/spell-check/SpellCheckAdapter" "ide/editor/directives/aceEditor/highlights/HighlightsManager" "ide/editor/directives/aceEditor/cursor-position/CursorPositionManager" "ide/editor/directives/aceEditor/track-changes/TrackChangesManager" @@ -15,7 +16,7 @@ define [ "ide/graphics/services/graphics" "ide/preamble/services/preamble" "ide/files/services/files" -], (App, Ace, SearchBox, Vim, ModeList, UndoManager, AutoCompleteManager, SpellCheckManager, HighlightsManager, CursorPositionManager, TrackChangesManager, MetadataManager) -> +], (App, Ace, SearchBox, Vim, ModeList, UndoManager, AutoCompleteManager, SpellCheckManager, SpellCheckAdapter, HighlightsManager, CursorPositionManager, TrackChangesManager, MetadataManager) -> EditSession = ace.require('ace/edit_session').EditSession ModeList = ace.require('ace/ext/modelist') Vim = ace.require('ace/keyboard/vim').Vim @@ -103,7 +104,8 @@ define [ if scope.spellCheck # only enable spellcheck when explicitly required spellCheckCache = $cacheFactory.get("spellCheck-#{scope.name}") || $cacheFactory("spellCheck-#{scope.name}", {capacity: 1000}) - spellCheckManager = new SpellCheckManager(scope, editor, element, spellCheckCache, $http, $q) + spellCheckManager = new SpellCheckManager(scope, spellCheckCache, $http, $q, new SpellCheckAdapter(editor)) + undoManager = new UndoManager(scope, editor, element) highlightsManager = new HighlightsManager(scope, editor, element) cursorPositionManager = new CursorPositionManager(scope, editor, element, localStorage) @@ -361,6 +363,23 @@ define [ session.setScrollTop(session.getScrollTop() + 1) session.setScrollTop(session.getScrollTop() - 1) + onSessionChangeForSpellCheck = (e) -> + spellCheckManager.onSessionChange() + e.oldSession?.getDocument().off "change", spellCheckManager.onChange + e.session.getDocument().on "change", spellCheckManager.onChange + e.oldSession?.off "changeScrollTop", spellCheckManager.onScroll + e.session.on "changeScrollTop", spellCheckManager.onScroll + + initSpellCheck = () -> + spellCheckManager.init() + editor.on 'changeSession', onSessionChangeForSpellCheck + onSessionChangeForSpellCheck({ session: editor.getSession() }) # Force initial setup + editor.on 'nativecontextmenu', spellCheckManager.onContextMenu + + tearDownSpellCheck = () -> + editor.off 'changeSession', onSessionChangeForSpellCheck + editor.off 'nativecontextmenu', spellCheckManager.onContextMenu + attachToAce = (sharejs_doc) -> lines = sharejs_doc.getSnapshot().split("\n") session = editor.getSession() @@ -406,6 +425,7 @@ define [ editor.initing = false # now ready to edit document editor.setReadOnly(scope.readOnly) # respect the readOnly setting, normally false + initSpellCheck() resetScrollMargins() @@ -467,6 +487,7 @@ define [ scope.$on '$destroy', () -> if scope.sharejsDoc? + tearDownSpellCheck() detachFromAce(scope.sharejsDoc) session = editor.getSession() session?.destroy() @@ -488,22 +509,14 @@ define [ >Dismiss
- +
- @row = options.row - @column = options.column + constructor: (@markerId, @range, options) -> @word = options.word @suggestions = options.suggestions class HighlightedWordManager constructor: (@editor) -> @reset() - - reset: () -> - @highlights = rows: [] - addHighlight: (highlight) -> - unless highlight instanceof Highlight - highlight = new Highlight(highlight) - range = new Range( - highlight.row, highlight.column, - highlight.row, highlight.column + highlight.word.length - ) - highlight.markerId = @editor.getSession().addMarker range, "spelling-highlight", 'text', false - @highlights.rows[highlight.row] ||= [] - @highlights.rows[highlight.row].push highlight + reset: () -> + @highlights?.forEach (highlight) => + @editor.getSession().removeMarker(highlight.markerId) + @highlights = [] + + addHighlight: (options) -> + session = @editor.getSession() + doc = session.getDocument() + # Set up Range that will automatically update it's positions when the + # document changes + range = new Range() + range.start = doc.createAnchor({ + row: options.row, + column: options.column + }) + range.end = doc.createAnchor({ + row: options.row, + column: options.column + options.word.length + }) + # Prevent range from adding newly typed characters to the end of the word. + # This makes it appear as if the spelling error continues to the next word + # even after a space + range.end.$insertRight = true + + markerId = session.addMarker range, "spelling-highlight", 'text', false + + @highlights.push new Highlight(markerId, range, options) removeHighlight: (highlight) -> @editor.getSession().removeMarker(highlight.markerId) - for h, i in @highlights.rows[highlight.row] - if h == highlight - @highlights.rows[highlight.row].splice(i, 1) + @highlights = @highlights.filter (hl) -> + hl != highlight removeWord: (word) -> - toRemove = [] - for row in @highlights.rows - for highlight in (row || []) - if highlight.word == word - toRemove.push(highlight) - for highlight in toRemove - @removeHighlight highlight + @highlights.filter (highlight) -> + highlight.word == word + .forEach (highlight) => + @removeHighlight(highlight) - moveHighlight: (highlight, position) -> - @removeHighlight highlight - highlight.row = position.row - highlight.column = position.column - @addHighlight highlight - - clearRows: (from, to) -> - from ||= 0 - to ||= @highlights.rows.length - 1 - for row in @highlights.rows.slice(from, to + 1) - for highlight in (row || []).slice(0) - @removeHighlight highlight - - insertRows: (offset, number) -> - # rows are inserted after offset. i.e. offset row is not modified - affectedHighlights = [] - for row in @highlights.rows.slice(offset) - affectedHighlights.push(highlight) for highlight in (row || []) - for highlight in affectedHighlights - @moveHighlight highlight, - row: highlight.row + number - column: highlight.column - - removeRows: (offset, number) -> - # offset is the first row to delete - affectedHighlights = [] - for row in @highlights.rows.slice(offset) - affectedHighlights.push(highlight) for highlight in (row || []) - for highlight in affectedHighlights - if highlight.row >= offset + number - @moveHighlight highlight, - row: highlight.row - number - column: highlight.column - else - @removeHighlight highlight + clearRow: (row) -> + @highlights.filter (highlight) -> + highlight.range.start.row == row + .forEach (highlight) => + @removeHighlight(highlight) findHighlightWithinRange: (range) -> - rows = @highlights.rows.slice(range.start.row, range.end.row + 1) - for row in rows - for highlight in (row || []) - if @_doesHighlightOverlapRange(highlight, range.start, range.end) - return highlight - return null - - applyChange: (change) -> - start = change.start - end = change.end - if change.action == "insert" - if start.row != end.row - rowsAdded = end.row - start.row - @insertRows start.row + 1, rowsAdded - # make a copy since we're going to modify in place - oldHighlights = (@highlights.rows[start.row] || []).slice(0) - for highlight in oldHighlights - if highlight.column > start.column - # insertion was fully before this highlight - @moveHighlight highlight, - row: end.row - column: highlight.column + (end.column - start.column) - else if highlight.column + highlight.word.length >= start.column - # insertion was inside this highlight - @removeHighlight highlight - - else if change.action == "remove" - if start.row == end.row - oldHighlights = (@highlights.rows[start.row] || []).slice(0) - else - rowsRemoved = end.row - start.row - oldHighlights = - (@highlights.rows[start.row] || []).concat( - (@highlights.rows[end.row] || []) - ) - @removeRows start.row + 1, rowsRemoved - - for highlight in oldHighlights - if @_doesHighlightOverlapRange highlight, start, end - @removeHighlight highlight - else if @_isHighlightAfterRange highlight, start, end - @moveHighlight highlight, - row: start.row - column: highlight.column - (end.column - start.column) + _.find @highlights, (highlight) => + @_doesHighlightOverlapRange highlight, range.start, range.end _doesHighlightOverlapRange: (highlight, start, end) -> + highlightRow = highlight.range.start.row + highlightStartColumn = highlight.range.start.column + highlightEndColumn = highlight.range.end.column + highlightIsAllBeforeRange = - highlight.row < start.row or - (highlight.row == start.row and highlight.column + highlight.word.length <= start.column) + highlightRow < start.row or + (highlightRow == start.row and highlightEndColumn <= start.column) highlightIsAllAfterRange = - highlight.row > end.row or - (highlight.row == end.row and highlight.column >= end.column) + highlightRow > end.row or + (highlightRow == end.row and highlightStartColumn >= end.column) !(highlightIsAllBeforeRange or highlightIsAllAfterRange) - _isHighlightAfterRange: (highlight, start, end) -> - return true if highlight.row > end.row - return false if highlight.row < end.row - highlight.column >= end.column - + clearHighlightTouchingRange: (range) -> + highlight = _.find @highlights, (hl) => + @_doesHighlightTouchRange hl, range.start, range.end + if highlight + @removeHighlight highlight + _doesHighlightTouchRange: (highlight, start, end) -> + highlightRow = highlight.range.start.row + highlightStartColumn = highlight.range.start.column + highlightEndColumn = highlight.range.end.column - - - + rangeStartIsWithinHighlight = + highlightStartColumn <= start.column and + highlightEndColumn >= start.column + rangeEndIsWithinHighlight = + highlightStartColumn <= end.column and + highlightEndColumn >= end.column + highlightRow == start.row and + (rangeStartIsWithinHighlight or rangeEndIsWithinHighlight) diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckAdapter.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckAdapter.coffee new file mode 100644 index 0000000000..2afc2cf0ff --- /dev/null +++ b/services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckAdapter.coffee @@ -0,0 +1,56 @@ +define [ + "ace/ace" + "ide/editor/directives/aceEditor/spell-check/HighlightedWordManager" +], (Ace, HighlightedWordManager) -> + Range = ace.require('ace/range').Range + + class SpellCheckAdapter + constructor: (@editor) -> + @highlightedWordManager = new HighlightedWordManager(@editor) + + getLines: () -> + @editor.getValue().split('\n') + + normalizeChangeEvent: (e) -> e + + getCoordsFromContextMenuEvent: (e) -> + e.domEvent.stopPropagation() + return { + x: e.domEvent.clientX, + y: e.domEvent.clientY + } + + preventContextMenuEventDefault: (e) -> + e.domEvent.preventDefault() + + getHighlightFromCoords: (coords) -> + position = @editor.renderer.screenToTextCoordinates(coords.x, coords.y) + @highlightedWordManager.findHighlightWithinRange({ + start: position + end: position + }) + + selectHighlightedWord: (highlight) -> + row = highlight.range.start.row + startColumn = highlight.range.start.column + endColumn = highlight.range.end.column + + @editor.getSession().getSelection().setSelectionRange( + new Range( + row, startColumn, + row, endColumn + ) + ) + + replaceWord: (highlight, newWord) => + row = highlight.range.start.row + startColumn = highlight.range.start.column + endColumn = highlight.range.end.column + + @editor.getSession().replace(new Range( + row, startColumn, + row, endColumn + ), newWord) + + # Bring editor back into focus after clicking on suggestion + @editor.focus() diff --git a/services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.coffee b/services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.coffee index acbd636531..cbe4fdbd64 100644 --- a/services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.coffee +++ b/services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.coffee @@ -1,129 +1,88 @@ -define [ - "ide/editor/directives/aceEditor/spell-check/HighlightedWordManager" - "ace/ace" -], (HighlightedWordManager) -> - Range = ace.require("ace/range").Range - +define [], () -> class SpellCheckManager - constructor: (@$scope, @editor, @element, @cache, @$http, @$q) -> - $(document.body).append @element.find(".spell-check-menu") + constructor: (@$scope, @cache, @$http, @$q, @adapter) -> + @$scope.spellMenu = { + open: false + top: '0px' + left: '0px' + suggestions: [] + } @inProgressRequest = null @updatedLines = [] - @highlightedWordManager = new HighlightedWordManager(@editor) - @$scope.$watch "spellCheckLanguage", (language, oldLanguage) => + @$scope.$watch 'spellCheckLanguage', (language, oldLanguage) => if language != oldLanguage and oldLanguage? @runFullCheck() - onChange = (e) => - @runCheckOnChange(e) - - onScroll = () => - @closeContextMenu() + @$scope.replaceWord = @adapter.replaceWord + @$scope.learnWord = @learnWord - @editor.on "changeSession", (e) => - @highlightedWordManager.reset() - if @inProgressRequest? - @inProgressRequest.abort() - - if @$scope.spellCheckEnabled and @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != "" - @runSpellCheckSoon(200) - - e.oldSession?.getDocument().off "change", onChange - e.session.getDocument().on "change", onChange - - e.oldSession?.off "changeScrollTop", onScroll - e.session.on "changeScrollTop", onScroll - - @$scope.spellingMenu = {left: '0px', top: '0px'} - - @editor.on "nativecontextmenu", (e) => - e.domEvent.stopPropagation(); - @closeContextMenu(e.domEvent) - @openContextMenu(e.domEvent) - - $(document).on "click", (e) => - if e.which != 3 # Ignore if this was a right click - @closeContextMenu(e) + $(document).on 'click', (e) => + @closeContextMenu() if e.which != 3 # Ignore if right click return true - @$scope.replaceWord = (highlight, suggestion) => - @replaceWord(highlight, suggestion) + init: () -> + @updatedLines = Array(@adapter.getLines().length).fill(true) + @runSpellCheckSoon(200) if @isSpellCheckEnabled() - @$scope.learnWord = (highlight) => - @learnWord(highlight) + isSpellCheckEnabled: () -> + return !!( + @$scope.spellCheck and + @$scope.spellCheckLanguage and + @$scope.spellCheckLanguage != '' + ) - runFullCheck: () -> - @highlightedWordManager.clearRows() - if @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != "" - @runSpellCheck() + onChange: (e) => + if @isSpellCheckEnabled() + @markLinesAsUpdated(@adapter.normalizeChangeEvent(e)) + + @adapter.highlightedWordManager.clearHighlightTouchingRange(e) - runCheckOnChange: (e) -> - if @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != "" - @highlightedWordManager.applyChange(e) - @markLinesAsUpdated(e) @runSpellCheckSoon() + onSessionChange: () => + @adapter.highlightedWordManager.reset() + @inProgressRequest.abort() if @inProgressRequest? + + @runSpellCheckSoon(200) if @isSpellCheckEnabled() + + onContextMenu: (e) => + @closeContextMenu() + @openContextMenu(e) + + onScroll: () => @closeContextMenu() + openContextMenu: (e) -> - position = @editor.renderer.screenToTextCoordinates(e.clientX, e.clientY) - highlight = @highlightedWordManager.findHighlightWithinRange - start: position - end: position - - @$scope.$apply () => - @$scope.spellingMenu.highlight = highlight - + coords = @adapter.getCoordsFromContextMenuEvent(e) + highlight = @adapter.getHighlightFromCoords(coords) if highlight - e.stopPropagation() - e.preventDefault() - - @editor.getSession().getSelection().setSelectionRange( - new Range( - highlight.row, highlight.column - highlight.row, highlight.column + highlight.word.length - ) - ) - + @adapter.preventContextMenuEventDefault(e) + @adapter.selectHighlightedWord(highlight) @$scope.$apply () => - @$scope.spellingMenu.open = true - @$scope.spellingMenu.left = e.clientX + 'px' - @$scope.spellingMenu.top = e.clientY + 'px' + @$scope.spellMenu = { + open: true + top: coords.y + 'px' + left: coords.x + 'px' + highlight: highlight + } return false - closeContextMenu: (e) -> - # this is triggered on scroll, so for performance only apply - # setting when it changes - if @$scope?.spellingMenu?.open != false + closeContextMenu: () -> + # This is triggered on scroll, so for performance only apply setting when + # it changes + if @$scope?.spellMenu and @$scope.spellMenu.open != false @$scope.$apply () => - @$scope.spellingMenu.open = false + @$scope.spellMenu.open = false - replaceWord: (highlight, text) -> - @editor.getSession().replace(new Range( - highlight.row, highlight.column, - highlight.row, highlight.column + highlight.word.length - ), text) - - learnWord: (highlight) -> + learnWord: (highlight) => @apiRequest "/learn", word: highlight.word - @highlightedWordManager.removeWord highlight.word + @adapter.highlightedWordManager.removeWord highlight.word language = @$scope.spellCheckLanguage @cache?.put("#{language}:#{highlight.word}", true) - getHighlightedWordAtCursor: () -> - cursor = @editor.getCursorPosition() - highlight = @highlightedWordManager.findHighlightWithinRange - start: cursor - end: cursor - return highlight - - runSpellCheckSoon: (delay = 1000) -> - run = () => - delete @timeoutId - @runSpellCheck(@updatedLines) - @updatedLines = [] - if @timeoutId? - clearTimeout @timeoutId - @timeoutId = setTimeout run, delay + runFullCheck: () -> + @adapter.highlightedWordManager.reset() + @runSpellCheck() if @isSpellCheckEnabled() markLinesAsUpdated: (change) -> start = change.start @@ -146,6 +105,15 @@ define [ @updatedLines[start.row] = true removeLines() + runSpellCheckSoon: (delay = 1000) -> + run = () => + delete @timeoutId + @runSpellCheck(@updatedLines) + @updatedLines = [] + if @timeoutId? + clearTimeout @timeoutId + @timeoutId = setTimeout run, delay + runSpellCheck: (linesToProcess) -> {words, positions} = @getWords(linesToProcess) language = @$scope.spellCheckLanguage @@ -178,11 +146,11 @@ define [ displayResult = (highlights) => if linesToProcess? for shouldProcess, row in linesToProcess - @highlightedWordManager.clearRows(row, row) if shouldProcess + @adapter.highlightedWordManager.clearRow(row) if shouldProcess else - @highlightedWordManager.clearRows() + @adapter.highlightedWordManager.reset() for highlight in highlights - @highlightedWordManager.addHighlight highlight + @adapter.highlightedWordManager.addHighlight highlight if not words.length displayResult highlights @@ -212,8 +180,24 @@ define [ seen[key] = true displayResult highlights + apiRequest: (endpoint, data, callback = (error, result) ->)-> + data.token = window.user.id + data._csrf = window.csrfToken + # use angular timeout option to cancel request if doc is changed + requestHandler = @$q.defer() + options = {timeout: requestHandler.promise} + httpRequest = @$http.post("/spelling" + endpoint, data, options) + .then (response) => + callback(null, response.data) + .catch (response) => + callback(new Error('api failure')) + # provide a method to cancel the request + abortRequest = () -> + requestHandler.resolve() + return { abort: abortRequest } + getWords: (linesToProcess) -> - lines = @editor.getValue().split("\n") + lines = @adapter.getLines() words = [] positions = [] for line, row in lines @@ -232,22 +216,6 @@ define [ words.push(word) return words: words, positions: positions - apiRequest: (endpoint, data, callback = (error, result) ->)-> - data.token = window.user.id - data._csrf = window.csrfToken - # use angular timeout option to cancel request if doc is changed - requestHandler = @$q.defer() - options = {timeout: requestHandler.promise} - httpRequest = @$http.post("/spelling" + endpoint, data, options) - .then (response) => - callback(null, response.data) - .catch (response) => - callback(new Error('api failure')) - # provide a method to cancel the request - abortRequest = () -> - requestHandler.resolve() - return { abort: abortRequest } - blacklistedCommandRegex: /// \\ # initial backslash (label # any of these commands diff --git a/services/web/public/stylesheets/app/editor/rich-text.less b/services/web/public/stylesheets/app/editor/rich-text.less index 493540e705..37a896269d 100644 --- a/services/web/public/stylesheets/app/editor/rich-text.less +++ b/services/web/public/stylesheets/app/editor/rich-text.less @@ -219,4 +219,11 @@ font-style: italic; color: #999; } + + .spelling-error { + background-image: url(/img/spellcheck-underline.png); + background-repeat: repeat-x; + background-position: bottom; + } } + diff --git a/services/web/test/unit_frontend/coffee/ide/editor/aceEditor/spell-check/SpellCheckManagerTests.coffee b/services/web/test/unit_frontend/coffee/ide/editor/aceEditor/spell-check/SpellCheckManagerTests.coffee new file mode 100644 index 0000000000..1b0dddced6 --- /dev/null +++ b/services/web/test/unit_frontend/coffee/ide/editor/aceEditor/spell-check/SpellCheckManagerTests.coffee @@ -0,0 +1,46 @@ +define [ + 'ide/editor/directives/aceEditor/spell-check/SpellCheckManager' +], (SpellCheckManager) -> + describe 'SpellCheckManager', -> + beforeEach (done) -> + @timelord = sinon.useFakeTimers() + + window.user = { id: 1 } + window.csrfToken = 'token' + @scope = { + $watch: sinon.stub() + spellCheck: true + spellCheckLanguage: 'en' + } + @highlightedWordManager = { + reset: sinon.stub() + clearRow: sinon.stub() + addHighlight: sinon.stub() + } + @adapter = { + getLines: sinon.stub() + highlightedWordManager: @highlightedWordManager + } + inject ($q, $http, $httpBackend, $cacheFactory) => + @$http = $http + @$q = $q + @$httpBackend = $httpBackend + cache = $cacheFactory('spellCheckTest', {capacity: 1000}) + @spellCheckManager = new SpellCheckManager(@scope, cache, $http, $q, @adapter) + done() + + afterEach -> + @timelord.restore() + + it 'runs a full check soon after init', () -> + @$httpBackend.when('POST', '/spelling/check').respond({ + misspellings: [{ + index: 0 + suggestions: ['opposition'] + }] + }) + @adapter.getLines.returns(['oppozition']) + @spellCheckManager.init() + @timelord.tick(200) + @$httpBackend.flush() + expect(@highlightedWordManager.addHighlight).to.have.been.called