Procházet zdrojové kódy

Get spell check working in new editor

James Allen před 12 roky
rodič
revize
5922ed45ee

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

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

+ 22 - 2
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</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>
 			"""
 		}

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

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

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

+ 11 - 5
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;