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

Rich text spelling
This commit is contained in:
Alasdair Smith 2018-05-21 11:50:30 +01:00 committed by GitHub
commit 5b3fbe47db
8 changed files with 329 additions and 255 deletions

View file

@ -1,5 +1,6 @@
define [
"ide/editor/Document"
"ide/editor/components/spellMenu"
"ide/editor/directives/aceEditor"
"ide/editor/directives/toggleSwitch"
"ide/editor/controllers/SavingNotificationController"

View file

@ -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>
"""
}

View file

@ -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"

View file

@ -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
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)

View file

@ -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()

View file

@ -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

View file

@ -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;
}
}

View file

@ -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