Get spell check working in new editor

This commit is contained in:
James Allen 2014-06-25 16:06:04 +01:00
parent 9dfbb36a83
commit 5922ed45ee
7 changed files with 465 additions and 7 deletions

View file

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

View file

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

View file

@ -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</a>
</div>
<div class="ace-editor-body"></div>
<div
id="spellCheckMenu"
class="dropdown context-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>
</div>
"""
}

View file

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

View file

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

View file

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

View file

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