diff --git a/services/web/app/views/project/editor.jade b/services/web/app/views/project/editor.jade index 1fe9fd8806..e3f038aa67 100644 --- a/services/web/app/views/project/editor.jade +++ b/services/web/app/views/project/editor.jade @@ -141,6 +141,7 @@ block content keybindings="settings.mode", font-size="settings.fontSize", auto-complete="settings.autoComplete", + spell-check-language="project.spellCheckLanguage", show-print-margin="false", sharejs-doc="editor.sharejs_doc", last-updated="editor.last_updated" diff --git a/services/web/public/coffee/app/ide.coffee b/services/web/public/coffee/app/ide.coffee index abd7e6bead..030dfb6a27 100644 --- a/services/web/public/coffee/app/ide.coffee +++ b/services/web/public/coffee/app/ide.coffee @@ -17,6 +17,15 @@ define [ SettingsManager ) -> App.controller "IdeController", ["$scope", "$timeout", "ide", ($scope, $timeout, ide) -> + # Don't freak out if we're already in an apply callback + $scope.$originalApply = $scope.$apply + $scope.$apply = (fn = () ->) -> + phase = @$root.$$phase + if (phase == '$apply' || phase == '$digest') + fn() + else + this.$originalApply(fn); + $scope.state = { loading: true load_progress: 40 diff --git a/services/web/public/coffee/app/ide/editor/directives/aceEditor.coffee b/services/web/public/coffee/app/ide/editor/directives/aceEditor.coffee index ecf78816c0..eb095d165b 100644 --- a/services/web/public/coffee/app/ide/editor/directives/aceEditor.coffee +++ b/services/web/public/coffee/app/ide/editor/directives/aceEditor.coffee @@ -3,11 +3,12 @@ define [ "ace/ace" "ide/editor/undo/UndoManager" "ide/editor/auto-complete/AutoCompleteManager" + "ide/editor/spell-check/SpellCheckManager" "ace/keyboard/vim" "ace/keyboard/emacs" "ace/mode/latex" "ace/edit_session" -], (App, Ace, UndoManager, AutoCompleteManager) -> +], (App, Ace, UndoManager, AutoCompleteManager, SpellCheckManager) -> LatexMode = require("ace/mode/latex").Mode EditSession = require('ace/edit_session').EditSession @@ -21,13 +22,15 @@ define [ autoComplete: "=" sharejsDoc: "=" lastUpdated: "=" + spellCheckLanguage: "=" } link: (scope, element, attrs) -> - editor = Ace.edit(element.find(".ace-editor-body")[0]) + editor = window.editor = Ace.edit(element.find(".ace-editor-body")[0]) scope.undo = show_remote_warning: false autoCompleteManager = new AutoCompleteManager(editor) + spellCheckManager = new SpellCheckManager(scope, editor, element) # Prevert Ctrl|Cmd-S from triggering save dialog editor.commands.addCommand @@ -121,6 +124,23 @@ define [ >Dismiss
+ """ } diff --git a/services/web/public/coffee/app/ide/editor/spell-check/HighlightedWordManager.coffee b/services/web/public/coffee/app/ide/editor/spell-check/HighlightedWordManager.coffee new file mode 100644 index 0000000000..acd4c512e9 --- /dev/null +++ b/services/web/public/coffee/app/ide/editor/spell-check/HighlightedWordManager.coffee @@ -0,0 +1,150 @@ +define [ + "ace/range" +], () -> + Range = require("ace/range").Range + + class Highlight + constructor: (options) -> + @row = options.row + @column = options.column + @word = options.word + @suggestions = options.suggestions + + class HighlightedWordManager + constructor: (@editor) -> + @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" + @highlights.rows[highlight.row] ||= [] + @highlights.rows[highlight.row].push highlight + + 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) + + 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 + + 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 + + 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.range.start + end = change.range.end + if change.action == "insertText" + 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 == "insertLines" + @insertRows start.row, change.lines.length + + else if change.action == "removeText" + 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) + + else if change.action == "removeLines" + @removeRows start.row, change.lines.length + + _doesHighlightOverlapRange: (highlight, start, end) -> + highlightIsAllBeforeRange = + highlight.row < start.row or + (highlight.row == start.row and highlight.column + highlight.word.length <= start.column) + highlightIsAllAfterRange = + highlight.row > end.row or + (highlight.row == end.row and highlight.column >= 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 + + + + + + + diff --git a/services/web/public/coffee/app/ide/editor/spell-check/SpellCheckManager.coffee b/services/web/public/coffee/app/ide/editor/spell-check/SpellCheckManager.coffee new file mode 100644 index 0000000000..cc38486fb1 --- /dev/null +++ b/services/web/public/coffee/app/ide/editor/spell-check/SpellCheckManager.coffee @@ -0,0 +1,190 @@ +define [ + "ide/editor/spell-check/HighlightedWordManager" + "ace/range" +], (HighlightedWordManager) -> + Range = require("ace/range").Range + + class SpellCheckManager + constructor: (@$scope, @editor, @element) -> + @updatedLines = [] + @highlightedWordManager = new HighlightedWordManager(@editor) + + @$scope.$watch "spellCheckLanguage", (language, oldLanguage) => + if language != oldLanguage and oldLanguage? + @runFullCheck() + + @editor.on "changeSession", (e) => + @runFullCheck() + + doc = e.session.getDocument() + doc.on "change", (e) => + @runCheckOnChange(e) + + @$scope.spellingMenu = {left: '0px', top: '0px'} + + @editor.on "nativecontextmenu", (e) => + @closeContextMenu(e.domEvent) + @openContextMenu(e.domEvent) + + $(document).on "click", (e) => + @closeContextMenu(e) + # $(document).on "contextmenu", (e) => + # @closeContextMenu(e) + + @$scope.replaceWord = (highlight, suggestion) => + @replaceWord(highlight, suggestion) + + @$scope.learnWord = (highlight) => + @learnWord(highlight) + + runFullCheck: () -> + console.log "Running full check" + @highlightedWordManager.clearRows() + if @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != "" + @runSpellCheck() + + runCheckOnChange: (e) -> + console.log "Checking change", e.data + if @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != "" + @highlightedWordManager.applyChange(e.data) + @markLinesAsUpdated(e.data) + @runSpellCheckSoon() + + openContextMenu: (e) -> + position = @editor.renderer.screenToTextCoordinates(e.clientX, e.clientY) + highlight = @highlightedWordManager.findHighlightWithinRange + start: position + end: position + + @$scope.$apply () => + @$scope.spellingMenu.highlight = highlight + + console.log "highlight", @$scope.highlight_under_mouse + + if highlight + e.stopPropagation() + e.preventDefault() + + @editor.getSession().getSelection().setSelectionRange( + new Range( + highlight.row, highlight.column + highlight.row, highlight.column + highlight.word.length + ) + ) + + console.log "Height", @element.find(".context-menu").height() + + @$scope.$apply () => + @$scope.spellingMenu.open = true + @$scope.spellingMenu.left = e.clientX + 'px' + @$scope.spellingMenu.top = e.clientY + 'px' + + closeContextMenu: (e) -> + @$scope.$apply () => + @$scope.spellingMenu.open = false + + replaceWord: (highlight, text) -> + @editor.getSession().replace(new Range( + highlight.row, highlight.column, + highlight.row, highlight.column + highlight.word.length + ), text) + + learnWord: (highlight) -> + @apiRequest "/learn", word: highlight.word + @highlightedWordManager.removeWord highlight.word + + getHighlightedWordAtCursor: () -> + cursor = @editor.getCursorPosition() + highlight = @highlightedWordManager.findHighlightWithinRange + start: cursor + end: cursor + return highlight + + runSpellCheckSoon: () -> + run = () => + delete @timeoutId + @runSpellCheck(@updatedLines) + @updatedLines = [] + if @timeoutId? + clearTimeout @timeoutId + @timeoutId = setTimeout run, 1000 + + markLinesAsUpdated: (change) -> + start = change.range.start + end = change.range.end + + insertLines = () => + lines = end.row - start.row + while lines-- + @updatedLines.splice(start.row, 0, true) + + removeLines = () => + lines = end.row - start.row + while lines-- + @updatedLines.splice(start.row + 1, 1) + + if change.action == "insertText" + @updatedLines[start.row] = true + insertLines() + else if change.action == "removeText" + @updatedLines[start.row] = true + removeLines() + else if change.action == "insertLines" + insertLines() + else if change.action == "removeLines" + removeLines() + + runSpellCheck: (linesToProcess) -> + {words, positions} = @getWords(linesToProcess) + language = @$scope.spellCheckLanguage + @apiRequest "/check", {language: language, words: words}, (error, result) => + if error? or !result? or !result.misspellings? + return null + + if linesToProcess? + for shouldProcess, row in linesToProcess + @highlightedWordManager.clearRows(row, row) if shouldProcess + else + @highlightedWordManager.clearRows() + + for misspelling in result.misspellings + word = words[misspelling.index] + position = positions[misspelling.index] + @highlightedWordManager.addHighlight + column: position.column + row: position.row + word: word + suggestions: misspelling.suggestions + + getWords: (linesToProcess) -> + lines = @editor.getValue().split("\n") + words = [] + positions = [] + for line, row in lines + if !linesToProcess? or linesToProcess[row] + wordRegex = /\\?['a-zA-Z\u00C0-\u00FF]+/g + while (result = wordRegex.exec(line)) + word = result[0] + if word[0] == "'" + word = word.slice(1) + if word[word.length - 1] == "'" + word = word.slice(0,-1) + positions.push row: row, column: result.index + words.push(word) + return words: words, positions: positions + + apiRequest: (endpoint, data, callback = (error, result) ->)-> + data.token = window.user.id + data._csrf = window.csrfToken + options = + url: "/spelling" + endpoint + type: "POST" + dataType: "json" + headers: + "Content-Type": "application/json" + data: JSON.stringify data + success: (data, status, xhr) -> + callback null, data + error: (xhr, status, error) -> + callback error + $.ajax options diff --git a/services/web/public/coffee/app/ide/editor/spell-check/SpellingMenuView.coffee b/services/web/public/coffee/app/ide/editor/spell-check/SpellingMenuView.coffee new file mode 100644 index 0000000000..3ba9fd1d96 --- /dev/null +++ b/services/web/public/coffee/app/ide/editor/spell-check/SpellingMenuView.coffee @@ -0,0 +1,82 @@ +define [ + "libs/backbone" + "libs/mustache" +], () -> + SUGGESTIONS_TO_SHOW = 5 + + SpellingMenuView = Backbone.View.extend + templates: + menu: $("#spellingMenuTemplate").html() + entry: $("#spellingMenuEntryTemplate").html() + + events: + "click a#learnWord": -> + @trigger "click:learn", @_currentHighlight + @hide() + + initialize: (options) -> + @ide = options.ide + @ide.editor.getContainerElement().append @render().el + @ide.editor.on "click", () => @hide() + @ide.editor.on "scroll", () => @hide() + @ide.editor.on "update:doc", () => @hide() + @ide.editor.on "change:doc", () => @hide() + + render: () -> + @setElement($(@templates.menu)) + @$el.css "z-index" : 10000 + @$(".dropdown-toggle").dropdown() + @hide() + return @ + + showForHighlight: (highlight) -> + if @_currentHighlight? and highlight != @_currentHighlight + @_close() + + if !@_currentHighlight? + @_currentHighlight = highlight + @_setSuggestions(highlight) + position = @ide.editor.textToEditorCoordinates( + highlight.row + highlight.column + highlight.word.length + ) + @_position(position.x, position.y) + @_show() + + hideIfAppropriate: (cursorPosition) -> + if @_currentHighlight? + if !@_cursorCloseToHighlight(cursorPosition, @_currentHighlight) and !@_isOpen() + @hide() + + hide: () -> + delete @_currentHighlight + @_close() + @$el.hide() + + _setSuggestions: (highlight) -> + @$(".spelling-suggestion").remove() + divider = @$(".divider") + for suggestion in highlight.suggestions.slice(0, SUGGESTIONS_TO_SHOW) + do (suggestion) => + entry = $(Mustache.to_html(@templates.entry, word: suggestion)) + divider.before(entry) + entry.on "click", () => + @trigger "click:suggestion", suggestion, highlight + + _show: () -> @$el.show() + + _isOpen: () -> + @$(".dropdown-menu").is(":visible") + + _close: () -> + if @_isOpen() + @$el.dropdown("toggle") + + _cursorCloseToHighlight: (position, highlight) -> + position.row == highlight.row and + position.column >= highlight.column and + position.column <= highlight.column + highlight.word.length + 1 + + _position: (x,y) -> @$el.css left: x, top: y + + diff --git a/services/web/public/stylesheets/app/editor.less b/services/web/public/stylesheets/app/editor.less index db9eccd263..fe5202d8d1 100644 --- a/services/web/public/stylesheets/app/editor.less +++ b/services/web/public/stylesheets/app/editor.less @@ -148,11 +148,6 @@ padding: 0 12px; } } - - .context-menu { - position: fixed; - z-index: 100; - } } } @@ -192,6 +187,12 @@ width: 100%; height: 100%; } + .spelling-highlight { + position: absolute; + background-image: url(/img/spellcheck-underline.png); + background-repeat: repeat-x; + background-position: bottom left; + } } .ui-layout-resizer { @@ -233,6 +234,11 @@ background-color: #ddd; } +.context-menu { + position: fixed; + z-index: 100; +} + #left-menu { position: absolute; width: 210px;