mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-07 20:31:06 -05:00
Get spell check working in new editor
This commit is contained in:
parent
9dfbb36a83
commit
5922ed45ee
7 changed files with 465 additions and 7 deletions
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
"""
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue