Просмотр исходного кода

Merge pull request #528 from sharelatex/as-cm-spelling

Rich text spelling
Alasdair Smith 8 лет назад
Родитель
Сommit
5b3fbe47db

+ 1 - 0
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"

+ 34 - 0
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: """
+  <div
+    class="dropdown context-menu spell-check-menu"
+    ng-show="$ctrl.open"
+    ng-style="{top: $ctrl.top, left: $ctrl.left}"
+    ng-class="{open: $ctrl.open}"
+  >
+    <ul class="dropdown-menu">
+      <li ng-repeat="suggestion in $ctrl.highlight.suggestions | limitTo:8">
+        <a
+          href
+          ng-click="$ctrl.replaceWord({ highlight: $ctrl.highlight, suggestion: suggestion })"
+        >
+          {{ suggestion }}
+        </a>
+      </li>
+      <li class="divider"></li>
+      <li>
+        <a href ng-click="$ctrl.learnWord({ highlight: $ctrl.highlight })">Add to Dictionary</a>
+      </li>
+    </ul>
+  </div>
+    """
+  }

+ 31 - 18
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</a>
 					</div>
 					<div class="ace-editor-body"></div>
-					<div
-						class="dropdown context-menu spell-check-menu"
-						ng-show="spellingMenu.open"
-						ng-style="{top: spellingMenu.top, left: spellingMenu.left}"
-						ng-class="{open: spellingMenu.open}"
-					>
-						<ul class="dropdown-menu">
-							<li ng-repeat="suggestion in spellingMenu.highlight.suggestions | limitTo:8">
-								<a href ng-click="replaceWord(spellingMenu.highlight, suggestion)">{{ suggestion }}</a>
-							</li>
-							<li class="divider"></li>
-							<li>
-								<a href ng-click="learnWord(spellingMenu.highlight)">Add to Dictionary</a>
-							</li>
-						</ul>
-					</div>
+					<spell-menu
+						open="spellMenu.open"
+						top="spellMenu.top"
+						left="spellMenu.left"
+						highlight="spellMenu.highlight"
+						replace-word="replaceWord(highlight, suggestion)"
+						learn-word="learnWord(highlight)"
+					></spell-menu>
 					<div
 						class="annotation-label"
 						ng-show="annotationLabel.show"

+ 66 - 117
services/web/public/coffee/ide/editor/directives/aceEditor/spell-check/HighlightedWordManager.coffee

@@ -4,144 +4,93 @@ define [
 	Range = ace.require("ace/range").Range
 
 	class Highlight
-		constructor: (options) ->
-			@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
+			@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)

+ 56 - 0
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()

+ 85 - 117
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()
+			$(document).on 'click', (e) =>
+				@closeContextMenu() if e.which != 3 # Ignore if right click
+				return true
 
-				if @$scope.spellCheckEnabled and @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != ""
-					@runSpellCheckSoon(200)
+		init: () ->
+			@updatedLines = Array(@adapter.getLines().length).fill(true)
+			@runSpellCheckSoon(200) if @isSpellCheckEnabled()
 
-				e.oldSession?.getDocument().off "change", onChange
-				e.session.getDocument().on "change", onChange
-				
-				e.oldSession?.off "changeScrollTop", onScroll
-				e.session.on "changeScrollTop", onScroll
+		isSpellCheckEnabled: () ->
+			return !!(
+				@$scope.spellCheck and
+				@$scope.spellCheckLanguage and
+				@$scope.spellCheckLanguage != ''
+			)
 
-			@$scope.spellingMenu = {left: '0px', top: '0px'}
+		onChange: (e) =>
+			if @isSpellCheckEnabled()
+				@markLinesAsUpdated(@adapter.normalizeChangeEvent(e))
 
-			@editor.on "nativecontextmenu", (e) =>
-				e.domEvent.stopPropagation();
-				@closeContextMenu(e.domEvent)
-				@openContextMenu(e.domEvent)
+				@adapter.highlightedWordManager.clearHighlightTouchingRange(e)
 
-			$(document).on "click", (e) =>
-				if e.which != 3 # Ignore if this was a right click
-					@closeContextMenu(e)
-				return true
+				@runSpellCheckSoon()
 
-			@$scope.replaceWord = (highlight, suggestion) =>
-				@replaceWord(highlight, suggestion)
+		onSessionChange: () =>
+			@adapter.highlightedWordManager.reset()
+			@inProgressRequest.abort() if @inProgressRequest?
 
-			@$scope.learnWord = (highlight) =>
-				@learnWord(highlight)
+			@runSpellCheckSoon(200) if @isSpellCheckEnabled()
 
-		runFullCheck: () ->
-			@highlightedWordManager.clearRows()
-			if @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != ""
-				@runSpellCheck()
+		onContextMenu: (e) =>
+			@closeContextMenu()
+			@openContextMenu(e)
 
-		runCheckOnChange: (e) ->
-			if @$scope.spellCheckLanguage and @$scope.spellCheckLanguage != ""
-				@highlightedWordManager.applyChange(e)
-				@markLinesAsUpdated(e)
-				@runSpellCheckSoon()
+		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

+ 7 - 0
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;
+  }
 }
+

+ 46 - 0
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