diff --git a/services/web/app/views/project/editor/file-tree.pug b/services/web/app/views/project/editor/file-tree.pug index c8aecd9a14..ba024b6d25 100644 --- a/services/web/app/views/project/editor/file-tree.pug +++ b/services/web/app/views/project/editor/file-tree.pug @@ -111,6 +111,7 @@ aside.editor-sidebar.full-size( jump-to-line="jumpToLine" on-toggle="onToggle" event-tracking="eventTracking" + highlighted-line="highlightedLine" ) diff --git a/services/web/frontend/js/ide/editor/EditorManager.js b/services/web/frontend/js/ide/editor/EditorManager.js index f05df497c8..f2daefac9d 100644 --- a/services/web/frontend/js/ide/editor/EditorManager.js +++ b/services/web/frontend/js/ide/editor/EditorManager.js @@ -124,6 +124,14 @@ export default (EditorManager = (function() { return this.openDoc(doc, options) } + jumpToLine(options) { + return this.$scope.$broadcast( + 'editor:gotoLine', + options.gotoLine, + options.gotoColumn + ) + } + openDoc(doc, options) { if (options == null) { options = {} @@ -137,13 +145,7 @@ export default (EditorManager = (function() { // allow Ace to display document before moving, delay until next tick // added delay to make this happen later that gotoStoredPosition in // CursorPositionManager - return setTimeout(() => { - return this.$scope.$broadcast( - 'editor:gotoLine', - options.gotoLine, - options.gotoColumn - ) - }, 0) + return setTimeout(() => this.jumpToLine(options), 0) } else if (options.gotoOffset != null) { return setTimeout(() => { return this.$scope.$broadcast( diff --git a/services/web/frontend/js/ide/editor/directives/aceEditor.js b/services/web/frontend/js/ide/editor/directives/aceEditor.js index 5bf776fbd5..dc11bbe84e 100644 --- a/services/web/frontend/js/ide/editor/directives/aceEditor.js +++ b/services/web/frontend/js/ide/editor/directives/aceEditor.js @@ -554,11 +554,26 @@ App.directive('aceEditor', function( return scope.$emit(`${scope.name}:change`) } + let currentFirstVisibleRow = null + const emitMiddleVisibleRowChanged = () => { + if (!window.user.alphaProgram) return + const firstVisibleRow = editor.getFirstVisibleRow() + if (firstVisibleRow === currentFirstVisibleRow) return + + currentFirstVisibleRow = firstVisibleRow + const lastVisibleRow = editor.getLastVisibleRow() + scope.$emit( + `scroll:editor:update`, + Math.floor((firstVisibleRow + lastVisibleRow) / 2) + ) + } + const onScroll = function(scrollTop) { if (scope.eventsBridge == null) { return } const height = editor.renderer.layerConfig.maxHeight + emitMiddleVisibleRowChanged() return scope.eventsBridge.emit('aceScroll', scrollTop, height) } diff --git a/services/web/frontend/js/ide/outline/OutlineManager.js b/services/web/frontend/js/ide/outline/OutlineManager.js index 7804aa7d8d..b1b6463acb 100644 --- a/services/web/frontend/js/ide/outline/OutlineManager.js +++ b/services/web/frontend/js/ide/outline/OutlineManager.js @@ -3,7 +3,7 @@ import './components/OutlinePane' import './components/OutlineRoot' import './components/OutlineList' import './components/OutlineItem' -import parseOutline from './OutlineParser' +import { matchOutline, nestOutline } from './OutlineParser' import isValidTeXFile from '../../main/is-valid-tex-file' class OutlineManager { @@ -12,9 +12,15 @@ class OutlineManager { this.scope = scope this.shareJsDoc = null this.isTexFile = false + this.flatOutline = [] this.outline = [] + this.highlightedLine = null + this.ignoreNextScroll = false + this.ignoreNextCursorUpdate = false scope.$on('doc:after-opened', () => { + this.ignoreNextScroll = true + this.ignoreNextCursorUpdate = true this.shareJsDoc = scope.editor.sharejs_doc this.isTexFile = isValidTeXFile(scope.editor.open_doc_name) this.updateOutline() @@ -31,6 +37,32 @@ class OutlineManager { this.updateOutline() this.broadcastChangeEvent() }) + + scope.$on('cursor:editor:update', (event, cursorPosition) => { + if (!window.user.alphaProgram) return + if (this.ignoreNextCursorUpdate) { + this.ignoreNextCursorUpdate = false + return + } + this.updateHighlightedLine(cursorPosition.row + 1) + this.broadcastChangeEvent() + }) + + scope.$on('scroll:editor:update', (event, middleVisibleRow) => { + if (!window.user.alphaProgram) return + if (this.ignoreNextScroll) { + this.ignoreNextScroll = false + return + } + + this.updateHighlightedLine(middleVisibleRow + 1) + this.broadcastChangeEvent() + }) + + scope.$watch('editor.showRichText', () => { + this.ignoreNextScroll = true + this.ignoreNextCursorUpdate = true + }) } updateOutline() { @@ -38,21 +70,33 @@ class OutlineManager { if (this.isTexFile) { const content = this.ide.editorManager.getCurrentDocValue() if (content) { - this.outline = parseOutline(content) + this.flatOutline = matchOutline(content) + this.outline = nestOutline(this.flatOutline) } } } + // set highlightedLine to the closest outline line above the editorLine + updateHighlightedLine(editorLine) { + let closestOutlineLine = null + for (let lineId = 0; lineId < this.flatOutline.length; lineId++) { + const outline = this.flatOutline[lineId] + if (editorLine < outline.line) break // editorLine is above + closestOutlineLine = outline.line + } + this.highlightedLine = closestOutlineLine + } + jumpToLine(line) { - this.ide.editorManager.openDocId(this.shareJsDoc.doc.doc_id, { - gotoLine: line - }) + this.ignoreNextScroll = true + this.ide.editorManager.jumpToLine({ gotoLine: line }) } broadcastChangeEvent() { this.scope.$broadcast('outline-manager:outline-changed', { isTexFile: this.isTexFile, - outline: this.outline + outline: this.outline, + highlightedLine: this.highlightedLine }) } } diff --git a/services/web/frontend/js/ide/outline/OutlineParser.js b/services/web/frontend/js/ide/outline/OutlineParser.js index 877d50dd53..5ce8479959 100644 --- a/services/web/frontend/js/ide/outline/OutlineParser.js +++ b/services/web/frontend/js/ide/outline/OutlineParser.js @@ -113,9 +113,4 @@ function nestOutline(flatOutline) { return nestedOutlines } -function parseOutline(content) { - const flatOutline = matchOutline(content) - return nestOutline(flatOutline) -} - -export default parseOutline +export { matchOutline, nestOutline } diff --git a/services/web/frontend/js/ide/outline/components/OutlineItem.js b/services/web/frontend/js/ide/outline/components/OutlineItem.js index 86d59060e3..e991848b9a 100644 --- a/services/web/frontend/js/ide/outline/components/OutlineItem.js +++ b/services/web/frontend/js/ide/outline/components/OutlineItem.js @@ -1,10 +1,11 @@ -import React, { useState } from 'react' +import React, { useState, useEffect, createRef } from 'react' import PropTypes from 'prop-types' import classNames from 'classnames' import OutlineList from './OutlineList' -function OutlineItem({ outlineItem, jumpToLine }) { +function OutlineItem({ outlineItem, jumpToLine, highlightedLine }) { const [expanded, setExpanded] = useState(true) + const titleElementRef = createRef() const mainItemClasses = classNames('outline-item', { 'outline-item-no-children': !outlineItem.children @@ -15,6 +16,10 @@ function OutlineItem({ outlineItem, jumpToLine }) { 'fa-angle-right': !expanded }) + const itemLinkClasses = classNames('outline-item-link', { + 'outline-item-link-highlight': highlightedLine === outlineItem.line + }) + function handleExpandCollapseClick() { setExpanded(!expanded) } @@ -23,6 +28,18 @@ function OutlineItem({ outlineItem, jumpToLine }) { jumpToLine(outlineItem.line) } + useEffect( + () => { + if (highlightedLine !== outlineItem.line) return + + titleElementRef.current.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }) + }, + [highlightedLine, titleElementRef] + ) + return (