diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a20d3427d..09b4a8bc7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -294,6 +294,9 @@ importers: '@types/http-errors': specifier: ^2.0.4 version: 2.0.4 + '@types/jquery': + specifier: ^3.5.30 + version: 3.5.30 '@types/jsdom': specifier: ^21.1.7 version: 21.1.7 @@ -1485,6 +1488,9 @@ packages: '@types/http-errors@2.0.4': resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} + '@types/jquery@3.5.30': + resolution: {integrity: sha512-nbWKkkyb919DOUxjmRVk8vwtDb0/k8FKncmUKFi+NY+QXqWltooxTrswvz4LspQwxvLdvzBN1TImr6cw3aQx2A==} + '@types/jsdom@21.1.7': resolution: {integrity: sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==} @@ -1572,6 +1578,9 @@ packages: '@types/sinonjs__fake-timers@8.1.5': resolution: {integrity: sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ==} + '@types/sizzle@2.3.8': + resolution: {integrity: sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==} + '@types/superagent@8.1.7': resolution: {integrity: sha512-NmIsd0Yj4DDhftfWvvAku482PZum4DBW7U51OvS8gvOkDDY0WT1jsVyDV3hK+vplrsYw8oDwi9QxOM7U68iwww==} @@ -5448,6 +5457,10 @@ snapshots: '@types/http-errors@2.0.4': {} + '@types/jquery@3.5.30': + dependencies: + '@types/sizzle': 2.3.8 + '@types/jsdom@21.1.7': dependencies: '@types/node': 20.14.10 @@ -5550,6 +5563,8 @@ snapshots: '@types/sinonjs__fake-timers@8.1.5': {} + '@types/sizzle@2.3.8': {} + '@types/superagent@8.1.7': dependencies: '@types/cookiejar': 2.1.5 diff --git a/src/package.json b/src/package.json index 6b19c014c..ab49c1ae7 100644 --- a/src/package.json +++ b/src/package.json @@ -87,6 +87,7 @@ "@types/express": "^4.17.21", "@types/formidable": "^3.4.5", "@types/http-errors": "^2.0.4", + "@types/jquery": "^3.5.30", "@types/jsdom": "^21.1.7", "@types/jsonwebtoken": "^9.0.6", "@types/mocha": "^10.0.7", diff --git a/src/static/js/ace2_inner.js b/src/static/js/ace2_inner.js index f62615da2..f2966203c 100644 --- a/src/static/js/ace2_inner.js +++ b/src/static/js/ace2_inner.js @@ -30,6 +30,8 @@ const setAssoc = Ace2Common.setAssoc; const noop = Ace2Common.noop; const hooks = require('./pluginfw/hooks'); +import Scroll from './scroll' + function Ace2Inner(editorInfo, cssManagers) { const makeChangesetTracker = require('./changesettracker').makeChangesetTracker; const colorutils = require('./colorutils').colorutils; @@ -42,7 +44,6 @@ function Ace2Inner(editorInfo, cssManagers) { const SkipList = require('./skiplist'); const undoModule = require('./undomodule').undoModule; const AttributeManager = require('./AttributeManager'); - const Scroll = require('./scroll'); const DEBUG = false; const THE_TAB = ' '; // 4 @@ -77,7 +78,7 @@ function Ace2Inner(editorInfo, cssManagers) { }; appendNewSideDivLine(); - const scroll = Scroll.init(outerWin); + const scroll = new Scroll(outerWin); let outsideKeyDown = noop; let outsideKeyPress = (e) => true; diff --git a/src/static/js/caretPosition.js b/src/static/js/caretPosition.ts similarity index 95% rename from src/static/js/caretPosition.js rename to src/static/js/caretPosition.ts index 2814da74a..23e956d30 100644 --- a/src/static/js/caretPosition.js +++ b/src/static/js/caretPosition.ts @@ -3,7 +3,7 @@ // One rep.line(div) can be broken in more than one line in the browser. // This function is useful to get the caret position of the line as // is represented by the browser -exports.getPosition = () => { +export const getPosition = () => { const range = getSelectionRange(); console.log("Getting range", range) if (!range || $(range.endContainer).closest('body')[0].id !== 'innerdocbody') return null; @@ -65,7 +65,7 @@ const getPositionOfElementOrSelection = (element) => { // where is the top of the previous line // [2] the line before is part of another rep line. It's possible this line has different margins // height. So we have to get the exactly position of the line -exports.getPositionTopOfPreviousBrowserLine = (caretLinePosition, rep) => { +export const getPositionTopOfPreviousBrowserLine = (caretLinePosition, rep) => { let previousLineTop = caretLinePosition.top - caretLinePosition.height; // [1] const isCaretLineFirstBrowserLine = caretLineIsFirstBrowserLine(caretLinePosition.top, rep); @@ -126,7 +126,7 @@ const getLastRootChildNode = (node) => { // So, we can use the caret line to calculate the bottom of the line. // [2] the next line is part of another rep line. // It's possible this line has different dimensions, so we have to get the exactly dimension of it -exports.getBottomOfNextBrowserLine = (caretLinePosition, rep) => { +export const getBottomOfNextBrowserLine = (caretLinePosition, rep) => { let nextLineBottom = caretLinePosition.bottom + caretLinePosition.height; // [1] const isCaretLineLastBrowserLine = caretLineIsLastBrowserLineOfRepLine(caretLinePosition.top, rep); @@ -154,7 +154,7 @@ const caretLineIsLastBrowserLineOfRepLine = (caretLineTop, rep) => { return lastRootChildNodePosition.top === caretLineTop; }; -const getPreviousVisibleLine = (line, rep) => { +export const getPreviousVisibleLine = (line, rep) => { const firstLineOfPad = 0; if (line <= firstLineOfPad) { return firstLineOfPad; @@ -166,9 +166,8 @@ const getPreviousVisibleLine = (line, rep) => { }; -exports.getPreviousVisibleLine = getPreviousVisibleLine; -const getNextVisibleLine = (line, rep) => { +export const getNextVisibleLine = (line, rep) => { const lastLineOfThePad = rep.lines.length() - 1; if (line >= lastLineOfThePad) { return lastLineOfThePad; @@ -178,7 +177,6 @@ const getNextVisibleLine = (line, rep) => { return getNextVisibleLine(line + 1, rep); } }; -exports.getNextVisibleLine = getNextVisibleLine; const isLineVisible = (line, rep) => rep.lines.atIndex(line).lineNode.offsetHeight > 0; diff --git a/src/static/js/scroll.js b/src/static/js/scroll.js deleted file mode 100644 index 6614a1973..000000000 --- a/src/static/js/scroll.js +++ /dev/null @@ -1,351 +0,0 @@ -'use strict'; - -/* - This file handles scroll on edition or when user presses arrow keys. - In this file we have two representations of line (browser and rep line). - Rep Line = a line in the way is represented by Etherpad(rep) (each
is a line) - Browser Line = each vertical line. A
can be break into more than one - browser line. -*/ -const caretPosition = require('./caretPosition'); - -function Scroll(outerWin) { - // scroll settings - this.scrollSettings = parent.parent.clientVars.scrollWhenFocusLineIsOutOfViewport; - - // DOM reference - this.outerWin = outerWin; - this.doc = this.outerWin.contentDocument; - this.rootDocument = parent.parent.document; -} - -Scroll.prototype.scrollWhenCaretIsInTheLastLineOfViewportWhenNecessary = - function (rep, isScrollableEvent, innerHeight) { - // are we placing the caret on the line at the bottom of viewport? - // And if so, do we need to scroll the editor, as defined on the settings.json? - const shouldScrollWhenCaretIsAtBottomOfViewport = - this.scrollSettings.scrollWhenCaretIsInTheLastLineOfViewport; - if (shouldScrollWhenCaretIsAtBottomOfViewport) { - // avoid scrolling when selection includes multiple lines -- - // user can potentially be selecting more lines - // than it fits on viewport - const multipleLinesSelected = rep.selStart[0] !== rep.selEnd[0]; - - // avoid scrolling when pad loads - if (isScrollableEvent && !multipleLinesSelected && this._isCaretAtTheBottomOfViewport(rep)) { - // when scrollWhenFocusLineIsOutOfViewport.percentage is 0, pixelsToScroll is 0 - const pixelsToScroll = this._getPixelsRelativeToPercentageOfViewport(innerHeight); - this._scrollYPage(pixelsToScroll); - } - } - }; - -Scroll.prototype.scrollWhenPressArrowKeys = function (arrowUp, rep, innerHeight) { - // if percentageScrollArrowUp is 0, let the scroll to be handled as default, put the previous - // rep line on the top of the viewport - if (this._arrowUpWasPressedInTheFirstLineOfTheViewport(arrowUp, rep)) { - const pixelsToScroll = this._getPixelsToScrollWhenUserPressesArrowUp(innerHeight); - - // by default, the browser scrolls to the middle of the viewport. To avoid the twist made - // when we apply a second scroll, we made it immediately (without animation) - this._scrollYPageWithoutAnimation(-pixelsToScroll); - } else { - this.scrollNodeVerticallyIntoView(rep, innerHeight); - } -}; - -// Some plugins might set a minimum height to the editor (ex: ep_page_view), so checking -// if (caretLine() === rep.lines.length() - 1) is not enough. We need to check if there are -// other lines after caretLine(), and all of them are out of viewport. -Scroll.prototype._isCaretAtTheBottomOfViewport = function (rep) { - // computing a line position using getBoundingClientRect() is expensive. - // (obs: getBoundingClientRect() is called on caretPosition.getPosition()) - // To avoid that, we only call this function when it is possible that the - // caret is in the bottom of viewport - const caretLine = rep.selStart[0]; - const lineAfterCaretLine = caretLine + 1; - const firstLineVisibleAfterCaretLine = caretPosition.getNextVisibleLine(lineAfterCaretLine, rep); - const caretLineIsPartiallyVisibleOnViewport = - this._isLinePartiallyVisibleOnViewport(caretLine, rep); - const lineAfterCaretLineIsPartiallyVisibleOnViewport = - this._isLinePartiallyVisibleOnViewport(firstLineVisibleAfterCaretLine, rep); - if (caretLineIsPartiallyVisibleOnViewport || lineAfterCaretLineIsPartiallyVisibleOnViewport) { - // check if the caret is in the bottom of the viewport - const caretLinePosition = caretPosition.getPosition(); - const viewportBottom = this._getViewPortTopBottom().bottom; - const nextLineBottom = caretPosition.getBottomOfNextBrowserLine(caretLinePosition, rep); - const nextLineIsBelowViewportBottom = nextLineBottom > viewportBottom; - return nextLineIsBelowViewportBottom; - } - return false; -}; - -Scroll.prototype._isLinePartiallyVisibleOnViewport = function (lineNumber, rep) { - const lineNode = rep.lines.atIndex(lineNumber); - const linePosition = this._getLineEntryTopBottom(lineNode); - const lineTop = linePosition.top; - const lineBottom = linePosition.bottom; - const viewport = this._getViewPortTopBottom(); - const viewportBottom = viewport.bottom; - const viewportTop = viewport.top; - - const topOfLineIsAboveOfViewportBottom = lineTop < viewportBottom; - const bottomOfLineIsOnOrBelowOfViewportBottom = lineBottom >= viewportBottom; - const topOfLineIsBelowViewportTop = lineTop >= viewportTop; - const topOfLineIsAboveViewportBottom = lineTop <= viewportBottom; - const bottomOfLineIsAboveViewportBottom = lineBottom <= viewportBottom; - const bottomOfLineIsBelowViewportTop = lineBottom >= viewportTop; - - return (topOfLineIsAboveOfViewportBottom && bottomOfLineIsOnOrBelowOfViewportBottom) || - (topOfLineIsBelowViewportTop && topOfLineIsAboveViewportBottom) || - (bottomOfLineIsAboveViewportBottom && bottomOfLineIsBelowViewportTop); -}; - -Scroll.prototype._getViewPortTopBottom = function () { - const theTop = this.getScrollY(); - const doc = this.doc; - const height = doc.documentElement.clientHeight; // includes padding - - // we have to get the exactly height of the viewport. - // So it has to subtract all the values which changes - // the viewport height (E.g. padding, position top) - const viewportExtraSpacesAndPosition = - this._getEditorPositionTop() + this._getPaddingTopAddedWhenPageViewIsEnable(); - return { - top: theTop, - bottom: (theTop + height - viewportExtraSpacesAndPosition), - }; -}; - -Scroll.prototype._getEditorPositionTop = function () { - const editor = parent.document.getElementsByTagName('iframe'); - const editorPositionTop = editor[0].offsetTop; - return editorPositionTop; -}; - -// ep_page_view adds padding-top, which makes the viewport smaller -Scroll.prototype._getPaddingTopAddedWhenPageViewIsEnable = function () { - const aceOuter = this.rootDocument.getElementsByName('ace_outer'); - const aceOuterPaddingTop = parseInt($(aceOuter).css('padding-top')); - return aceOuterPaddingTop; -}; - -Scroll.prototype._getScrollXY = function () { - const win = this.outerWin; - const odoc = this.doc; - if (typeof (win.pageYOffset) === 'number') { - return { - x: win.pageXOffset, - y: win.pageYOffset, - }; - } - const docel = odoc.documentElement; - if (docel && typeof (docel.scrollTop) === 'number') { - return { - x: docel.scrollLeft, - y: docel.scrollTop, - }; - } -}; - -Scroll.prototype.getScrollX = function () { - return this._getScrollXY().x; -}; - -Scroll.prototype.getScrollY = function () { - return this._getScrollXY().y; -}; - -Scroll.prototype.setScrollX = function (x) { - this.outerWin.scrollTo(x, this.getScrollY()); -}; - -Scroll.prototype.setScrollY = function (y) { - this.outerWin.scrollTo(this.getScrollX(), y); -}; - -Scroll.prototype.setScrollXY = function (x, y) { - this.outerWin.scrollTo(x, y); -}; - -Scroll.prototype._isCaretAtTheTopOfViewport = function (rep) { - const caretLine = rep.selStart[0]; - const linePrevCaretLine = caretLine - 1; - const firstLineVisibleBeforeCaretLine = - caretPosition.getPreviousVisibleLine(linePrevCaretLine, rep); - const caretLineIsPartiallyVisibleOnViewport = - this._isLinePartiallyVisibleOnViewport(caretLine, rep); - const lineBeforeCaretLineIsPartiallyVisibleOnViewport = - this._isLinePartiallyVisibleOnViewport(firstLineVisibleBeforeCaretLine, rep); - if (caretLineIsPartiallyVisibleOnViewport || lineBeforeCaretLineIsPartiallyVisibleOnViewport) { - const caretLinePosition = caretPosition.getPosition(); // get the position of the browser line - const viewportPosition = this._getViewPortTopBottom(); - const viewportTop = viewportPosition.top; - const viewportBottom = viewportPosition.bottom; - const caretLineIsBelowViewportTop = caretLinePosition.bottom >= viewportTop; - const caretLineIsAboveViewportBottom = caretLinePosition.top < viewportBottom; - const caretLineIsInsideOfViewport = - caretLineIsBelowViewportTop && caretLineIsAboveViewportBottom; - if (caretLineIsInsideOfViewport) { - const prevLineTop = caretPosition.getPositionTopOfPreviousBrowserLine(caretLinePosition, rep); - const previousLineIsAboveViewportTop = prevLineTop < viewportTop; - return previousLineIsAboveViewportTop; - } - } - return false; -}; - -// By default, when user makes an edition in a line out of viewport, this line goes -// to the edge of viewport. This function gets the extra pixels necessary to get the -// caret line in a position X relative to Y% viewport. -Scroll.prototype._getPixelsRelativeToPercentageOfViewport = - function (innerHeight, aboveOfViewport) { - let pixels = 0; - const scrollPercentageRelativeToViewport = this._getPercentageToScroll(aboveOfViewport); - if (scrollPercentageRelativeToViewport > 0 && scrollPercentageRelativeToViewport <= 1) { - pixels = parseInt(innerHeight * scrollPercentageRelativeToViewport); - } - return pixels; - }; - -// we use different percentages when change selection. It depends on if it is -// either above the top or below the bottom of the page -Scroll.prototype._getPercentageToScroll = function (aboveOfViewport) { - let percentageToScroll = this.scrollSettings.percentage.editionBelowViewport; - if (aboveOfViewport) { - percentageToScroll = this.scrollSettings.percentage.editionAboveViewport; - } - return percentageToScroll; -}; - -Scroll.prototype._getPixelsToScrollWhenUserPressesArrowUp = function (innerHeight) { - let pixels = 0; - const percentageToScrollUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp; - if (percentageToScrollUp > 0 && percentageToScrollUp <= 1) { - pixels = parseInt(innerHeight * percentageToScrollUp); - } - return pixels; -}; - -Scroll.prototype._scrollYPage = function (pixelsToScroll) { - const durationOfAnimationToShowFocusline = this.scrollSettings.duration; - if (durationOfAnimationToShowFocusline) { - this._scrollYPageWithAnimation(pixelsToScroll, durationOfAnimationToShowFocusline); - } else { - this._scrollYPageWithoutAnimation(pixelsToScroll); - } -}; - -Scroll.prototype._scrollYPageWithoutAnimation = function (pixelsToScroll) { - this.outerWin.scrollBy(0, pixelsToScroll); -}; - -Scroll.prototype._scrollYPageWithAnimation = - function (pixelsToScroll, durationOfAnimationToShowFocusline) { - const outerDocBody = this.doc.getElementById('outerdocbody'); - - // it works on later versions of Chrome - const $outerDocBody = $(outerDocBody); - this._triggerScrollWithAnimation( - $outerDocBody, pixelsToScroll, durationOfAnimationToShowFocusline); - - // it works on Firefox and earlier versions of Chrome - const $outerDocBodyParent = $outerDocBody.parent(); - this._triggerScrollWithAnimation( - $outerDocBodyParent, pixelsToScroll, durationOfAnimationToShowFocusline); - }; - -// using a custom queue and clearing it, we avoid creating a queue of scroll animations. -// So if this function is called twice quickly, only the last one runs. -Scroll.prototype._triggerScrollWithAnimation = - function ($elem, pixelsToScroll, durationOfAnimationToShowFocusline) { - // clear the queue of animation - $elem.stop('scrollanimation'); - $elem.animate({ - scrollTop: `+=${pixelsToScroll}`, - }, { - duration: durationOfAnimationToShowFocusline, - queue: 'scrollanimation', - }).dequeue('scrollanimation'); - }; - -// scrollAmountWhenFocusLineIsOutOfViewport is set to 0 (default), scroll it the minimum distance -// needed to be completely in view. If the value is greater than 0 and less than or equal to 1, -// besides of scrolling the minimum needed to be visible, it scrolls additionally -// (viewport height * scrollAmountWhenFocusLineIsOutOfViewport) pixels -Scroll.prototype.scrollNodeVerticallyIntoView = function (rep, innerHeight) { - const viewport = this._getViewPortTopBottom(); - - // when the selection changes outside of the viewport the browser automatically scrolls the line - // to inside of the viewport. Tested on IE, Firefox, Chrome in releases from 2015 until now - // So, when the line scrolled gets outside of the viewport we let the browser handle it. - const linePosition = caretPosition.getPosition(); - if (linePosition) { - const distanceOfTopOfViewport = linePosition.top - viewport.top; - const distanceOfBottomOfViewport = viewport.bottom - linePosition.bottom - linePosition.height; - const caretIsAboveOfViewport = distanceOfTopOfViewport < 0; - const caretIsBelowOfViewport = distanceOfBottomOfViewport < 0; - if (caretIsAboveOfViewport) { - const pixelsToScroll = - distanceOfTopOfViewport - this._getPixelsRelativeToPercentageOfViewport(innerHeight, true); - this._scrollYPage(pixelsToScroll); - } else if (caretIsBelowOfViewport) { - // setTimeout is required here as line might not be fully rendered onto the pad - setTimeout(() => { - const outer = window.parent; - // scroll to the very end of the pad outer - outer.scrollTo(0, outer[0].innerHeight); - }, 150); - // if the above setTimeout and functionality is removed then hitting an enter - // key while on the last line wont be an optimal user experience - // Details at: https://github.com/ether/etherpad-lite/pull/4639/files - } - } -}; - -Scroll.prototype._partOfRepLineIsOutOfViewport = function (viewportPosition, rep) { - const focusLine = (rep.selFocusAtStart ? rep.selStart[0] : rep.selEnd[0]); - const line = rep.lines.atIndex(focusLine); - const linePosition = this._getLineEntryTopBottom(line); - const lineIsAboveOfViewport = linePosition.top < viewportPosition.top; - const lineIsBelowOfViewport = linePosition.bottom > viewportPosition.bottom; - - return lineIsBelowOfViewport || lineIsAboveOfViewport; -}; - -Scroll.prototype._getLineEntryTopBottom = function (entry, destObj) { - const dom = entry.lineNode; - const top = dom.offsetTop; - const height = dom.offsetHeight; - const obj = (destObj || {}); - obj.top = top; - obj.bottom = (top + height); - return obj; -}; - -Scroll.prototype._arrowUpWasPressedInTheFirstLineOfTheViewport = function (arrowUp, rep) { - const percentageScrollArrowUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp; - return percentageScrollArrowUp && arrowUp && this._isCaretAtTheTopOfViewport(rep); -}; - -Scroll.prototype.getVisibleLineRange = function (rep) { - const viewport = this._getViewPortTopBottom(); - // console.log("viewport top/bottom: %o", viewport); - const obj = {}; - const self = this; - const start = rep.lines.search((e) => self._getLineEntryTopBottom(e, obj).bottom > viewport.top); - // return the first line that the top position is greater or equal than - // the viewport. That is the first line that is below the viewport bottom. - // So the line that is in the bottom of the viewport is the very previous one. - let end = rep.lines.search((e) => self._getLineEntryTopBottom(e, obj).top >= viewport.bottom); - if (end < start) end = start; // unlikely - // top.console.log(start+","+(end -1)); - return [start, end - 1]; -}; - -Scroll.prototype.getVisibleCharRange = function (rep) { - const lineRange = this.getVisibleLineRange(rep); - return [rep.lines.offsetOfIndex(lineRange[0]), rep.lines.offsetOfIndex(lineRange[1])]; -}; - -exports.init = (outerWin) => new Scroll(outerWin); diff --git a/src/static/js/scroll.ts b/src/static/js/scroll.ts new file mode 100644 index 000000000..0d2dac44c --- /dev/null +++ b/src/static/js/scroll.ts @@ -0,0 +1,337 @@ +import {getBottomOfNextBrowserLine, getNextVisibleLine, getPosition, getPositionTopOfPreviousBrowserLine, getPreviousVisibleLine} from './caretPosition'; + + +class Scroll { + private readonly outerWin: HTMLIFrameElement; + private readonly doc: Document; + private rootDocument: Document; + private scrollSettings: any; + + constructor(outerWin: HTMLIFrameElement) { + this.scrollSettings = window.clientVars.scrollWhenFocusLineIsOutOfViewport; + + // DOM reference + this.outerWin = outerWin; + this.doc = this.outerWin.contentDocument!; + this.rootDocument = parent.parent.document; + } + + scrollWhenCaretIsInTheLastLineOfViewportWhenNecessary(rep, isScrollableEvent, innerHeight) { + // are we placing the caret on the line at the bottom of viewport? + // And if so, do we need to scroll the editor, as defined on the settings.json? + const shouldScrollWhenCaretIsAtBottomOfViewport = + this.scrollSettings.scrollWhenCaretIsInTheLastLineOfViewport; + if (shouldScrollWhenCaretIsAtBottomOfViewport) { + // avoid scrolling when selection includes multiple lines -- + // user can potentially be selecting more lines + // than it fits on viewport + const multipleLinesSelected = rep.selStart[0] !== rep.selEnd[0]; + + // avoid scrolling when pad loads + if (isScrollableEvent && !multipleLinesSelected && this._isCaretAtTheBottomOfViewport(rep)) { + // when scrollWhenFocusLineIsOutOfViewport.percentage is 0, pixelsToScroll is 0 + const pixelsToScroll = this._getPixelsRelativeToPercentageOfViewport(innerHeight); + this._scrollYPage(pixelsToScroll); + } + } + } + + scrollWhenPressArrowKeys(arrowUp, rep, innerHeight) { + // if percentageScrollArrowUp is 0, let the scroll to be handled as default, put the previous + // rep line on the top of the viewport + if (this._arrowUpWasPressedInTheFirstLineOfTheViewport(arrowUp, rep)) { + const pixelsToScroll = this._getPixelsToScrollWhenUserPressesArrowUp(innerHeight); + + // by default, the browser scrolls to the middle of the viewport. To avoid the twist made + // when we apply a second scroll, we made it immediately (without animation) + this._scrollYPageWithoutAnimation(-pixelsToScroll); + } else { + this.scrollNodeVerticallyIntoView(rep, innerHeight); + } + } + + _isCaretAtTheBottomOfViewport(rep) { + // computing a line position using getBoundingClientRect() is expensive. + // (obs: getBoundingClientRect() is called on caretPosition.getPosition()) + // To avoid that, we only call this function when it is possible that the + // caret is in the bottom of viewport + const caretLine = rep.selStart[0]; + const lineAfterCaretLine = caretLine + 1; + const firstLineVisibleAfterCaretLine = getNextVisibleLine(lineAfterCaretLine, rep); + const caretLineIsPartiallyVisibleOnViewport = + this._isLinePartiallyVisibleOnViewport(caretLine, rep); + const lineAfterCaretLineIsPartiallyVisibleOnViewport = + this._isLinePartiallyVisibleOnViewport(firstLineVisibleAfterCaretLine, rep); + if (caretLineIsPartiallyVisibleOnViewport || lineAfterCaretLineIsPartiallyVisibleOnViewport) { + // check if the caret is in the bottom of the viewport + const caretLinePosition = getPosition(); + const viewportBottom = this._getViewPortTopBottom().bottom; + const nextLineBottom = getBottomOfNextBrowserLine(caretLinePosition, rep); + const nextLineIsBelowViewportBottom = nextLineBottom > viewportBottom; + return nextLineIsBelowViewportBottom; + } + return false; + }; + + _isLinePartiallyVisibleOnViewport(lineNumber, rep){ + const lineNode = rep.lines.atIndex(lineNumber); + const linePosition = this._getLineEntryTopBottom(lineNode); + const lineTop = linePosition.top; + const lineBottom = linePosition.bottom; + const viewport = this._getViewPortTopBottom(); + const viewportBottom = viewport.bottom; + const viewportTop = viewport.top; + + const topOfLineIsAboveOfViewportBottom = lineTop < viewportBottom; + const bottomOfLineIsOnOrBelowOfViewportBottom = lineBottom >= viewportBottom; + const topOfLineIsBelowViewportTop = lineTop >= viewportTop; + const topOfLineIsAboveViewportBottom = lineTop <= viewportBottom; + const bottomOfLineIsAboveViewportBottom = lineBottom <= viewportBottom; + const bottomOfLineIsBelowViewportTop = lineBottom >= viewportTop; + + return (topOfLineIsAboveOfViewportBottom && bottomOfLineIsOnOrBelowOfViewportBottom) || + (topOfLineIsBelowViewportTop && topOfLineIsAboveViewportBottom) || + (bottomOfLineIsAboveViewportBottom && bottomOfLineIsBelowViewportTop); + }; + + _getViewPortTopBottom() { + const theTop = this.getScrollY(); + const doc = this.doc; + const height = doc.documentElement.clientHeight; // includes padding + + // we have to get the exactly height of the viewport. + // So it has to subtract all the values which changes + // the viewport height (E.g. padding, position top) + const viewportExtraSpacesAndPosition = + this._getEditorPositionTop() + this._getPaddingTopAddedWhenPageViewIsEnable(); + return { + top: theTop, + bottom: (theTop + height - viewportExtraSpacesAndPosition), + }; + }; + + _getEditorPositionTop() { + const editor = parent.document.getElementsByTagName('iframe'); + const editorPositionTop = editor[0].offsetTop; + return editorPositionTop; + }; + + _getPaddingTopAddedWhenPageViewIsEnable() { + const aceOuter = this.rootDocument.getElementsByName('ace_outer'); + const aceOuterPaddingTop = parseInt($(aceOuter).css('padding-top')); + return aceOuterPaddingTop; + }; + + _getScrollXY() { + const win = this.outerWin; + const odoc = this.doc; + if (typeof (win.pageYOffset) === 'number') { + return { + x: win.pageXOffset, + y: win.pageYOffset, + }; + } + const docel = odoc.documentElement; + if (docel && typeof (docel.scrollTop) === 'number') { + return { + x: docel.scrollLeft, + y: docel.scrollTop, + }; + } + }; + + getScrollX() { + return this._getScrollXY().x; + }; + + getScrollY () { + return this._getScrollXY().y; + }; + + setScrollX(x) { + this.outerWin.scrollTo(x, this.getScrollY()); + }; + + setScrollY(y) { + this.outerWin.scrollTo(this.getScrollX(), y); + }; + + setScrollXY(x, y) { + this.outerWin.scrollTo(x, y); + }; + + _isCaretAtTheTopOfViewport(rep) { + const caretLine = rep.selStart[0]; + const linePrevCaretLine = caretLine - 1; + const firstLineVisibleBeforeCaretLine = + getPreviousVisibleLine(linePrevCaretLine, rep); + const caretLineIsPartiallyVisibleOnViewport = + this._isLinePartiallyVisibleOnViewport(caretLine, rep); + const lineBeforeCaretLineIsPartiallyVisibleOnViewport = + this._isLinePartiallyVisibleOnViewport(firstLineVisibleBeforeCaretLine, rep); + if (caretLineIsPartiallyVisibleOnViewport || lineBeforeCaretLineIsPartiallyVisibleOnViewport) { + const caretLinePosition = getPosition(); // get the position of the browser line + const viewportPosition = this._getViewPortTopBottom(); + const viewportTop = viewportPosition.top; + const viewportBottom = viewportPosition.bottom; + const caretLineIsBelowViewportTop = caretLinePosition.bottom >= viewportTop; + const caretLineIsAboveViewportBottom = caretLinePosition.top < viewportBottom; + const caretLineIsInsideOfViewport = + caretLineIsBelowViewportTop && caretLineIsAboveViewportBottom; + if (caretLineIsInsideOfViewport) { + const prevLineTop = getPositionTopOfPreviousBrowserLine(caretLinePosition, rep); + const previousLineIsAboveViewportTop = prevLineTop < viewportTop; + return previousLineIsAboveViewportTop; + } + } + return false; + }; + + // By default, when user makes an edition in a line out of viewport, this line goes +// to the edge of viewport. This function gets the extra pixels necessary to get the +// caret line in a position X relative to Y% viewport. + _getPixelsRelativeToPercentageOfViewport(innerHeight, aboveOfViewport) { + let pixels = 0; + const scrollPercentageRelativeToViewport = this._getPercentageToScroll(aboveOfViewport); + if (scrollPercentageRelativeToViewport > 0 && scrollPercentageRelativeToViewport <= 1) { + pixels = parseInt(innerHeight * scrollPercentageRelativeToViewport); + } + return pixels; + }; + + // we use different percentages when change selection. It depends on if it is +// either above the top or below the bottom of the page + _getPercentageToScroll(aboveOfViewport: boolean) { + let percentageToScroll = this.scrollSettings.percentage.editionBelowViewport; + if (aboveOfViewport) { + percentageToScroll = this.scrollSettings.percentage.editionAboveViewport; + } + return percentageToScroll; + }; + + _getPixelsToScrollWhenUserPressesArrowUp(innerHeight) { + let pixels = 0; + const percentageToScrollUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp; + if (percentageToScrollUp > 0 && percentageToScrollUp <= 1) { + pixels = parseInt(innerHeight * percentageToScrollUp); + } + return pixels; + }; + + _scrollYPage(pixelsToScroll) { + const durationOfAnimationToShowFocusline = this.scrollSettings.duration; + if (durationOfAnimationToShowFocusline) { + this._scrollYPageWithAnimation(pixelsToScroll, durationOfAnimationToShowFocusline); + } else { + this._scrollYPageWithoutAnimation(pixelsToScroll); + } + }; + + _scrollYPageWithoutAnimation(pixelsToScroll) { + this.outerWin.scrollBy(0, pixelsToScroll); + }; + + _scrollYPageWithAnimation(pixelsToScroll, durationOfAnimationToShowFocusline) { + const outerDocBody = this.doc.getElementById('outerdocbody'); + + // it works on later versions of Chrome + const $outerDocBody = $(outerDocBody); + this._triggerScrollWithAnimation( + $outerDocBody, pixelsToScroll, durationOfAnimationToShowFocusline); + + // it works on Firefox and earlier versions of Chrome + const $outerDocBodyParent = $outerDocBody.parent(); + this._triggerScrollWithAnimation( + $outerDocBodyParent, pixelsToScroll, durationOfAnimationToShowFocusline); + }; + + _triggerScrollWithAnimation($elem, pixelsToScroll, durationOfAnimationToShowFocusline) { + // clear the queue of animation + $elem.stop('scrollanimation'); + $elem.animate({ + scrollTop: `+=${pixelsToScroll}`, + }, { + duration: durationOfAnimationToShowFocusline, + queue: 'scrollanimation', + }).dequeue('scrollanimation'); + }; + + + + scrollNodeVerticallyIntoView(rep, innerHeight) { + const viewport = this._getViewPortTopBottom(); + + // when the selection changes outside of the viewport the browser automatically scrolls the line + // to inside of the viewport. Tested on IE, Firefox, Chrome in releases from 2015 until now + // So, when the line scrolled gets outside of the viewport we let the browser handle it. + const linePosition = getPosition(); + if (linePosition) { + const distanceOfTopOfViewport = linePosition.top - viewport.top; + const distanceOfBottomOfViewport = viewport.bottom - linePosition.bottom - linePosition.height; + const caretIsAboveOfViewport = distanceOfTopOfViewport < 0; + const caretIsBelowOfViewport = distanceOfBottomOfViewport < 0; + if (caretIsAboveOfViewport) { + const pixelsToScroll = + distanceOfTopOfViewport - this._getPixelsRelativeToPercentageOfViewport(innerHeight, true); + this._scrollYPage(pixelsToScroll); + } else if (caretIsBelowOfViewport) { + // setTimeout is required here as line might not be fully rendered onto the pad + setTimeout(() => { + const outer = window.parent; + // scroll to the very end of the pad outer + outer.scrollTo(0, outer[0].innerHeight); + }, 150); + // if the above setTimeout and functionality is removed then hitting an enter + // key while on the last line wont be an optimal user experience + // Details at: https://github.com/ether/etherpad-lite/pull/4639/files + } + } + }; + + _partOfRepLineIsOutOfViewport(viewportPosition, rep) { + const focusLine = (rep.selFocusAtStart ? rep.selStart[0] : rep.selEnd[0]); + const line = rep.lines.atIndex(focusLine); + const linePosition = this._getLineEntryTopBottom(line); + const lineIsAboveOfViewport = linePosition.top < viewportPosition.top; + const lineIsBelowOfViewport = linePosition.bottom > viewportPosition.bottom; + + return lineIsBelowOfViewport || lineIsAboveOfViewport; + }; + + _getLineEntryTopBottom(entry, destObj) { + const dom = entry.lineNode; + const top = dom.offsetTop; + const height = dom.offsetHeight; + const obj = (destObj || {}); + obj.top = top; + obj.bottom = (top + height); + return obj; + }; + + _arrowUpWasPressedInTheFirstLineOfTheViewport(arrowUp, rep) { + const percentageScrollArrowUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp; + return percentageScrollArrowUp && arrowUp && this._isCaretAtTheTopOfViewport(rep); + }; + + getVisibleLineRange(rep) { + const viewport = this._getViewPortTopBottom(); + // console.log("viewport top/bottom: %o", viewport); + const obj = {}; + const self = this; + const start = rep.lines.search((e) => self._getLineEntryTopBottom(e, obj).bottom > viewport.top); + // return the first line that the top position is greater or equal than + // the viewport. That is the first line that is below the viewport bottom. + // So the line that is in the bottom of the viewport is the very previous one. + let end = rep.lines.search((e) => self._getLineEntryTopBottom(e, obj).top >= viewport.bottom); + if (end < start) end = start; // unlikely + // top.console.log(start+","+(end -1)); + return [start, end - 1]; + }; + + getVisibleCharRange(rep) { + const lineRange = this.getVisibleLineRange(rep); + return [rep.lines.offsetOfIndex(lineRange[0]), rep.lines.offsetOfIndex(lineRange[1])]; + }; +} + +export default Scroll