etherpad-lite/src/static/js/scroll.js

489 lines
20 KiB
JavaScript
Raw Normal View History

2020-12-26 20:39:37 +00:00
'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 <div> is a line)
Browser Line = each vertical line. A <div> can be break into more than one
browser line.
*/
2020-11-23 13:24:19 -05:00
const caretPosition = require('./caretPosition');
function Scroll(outerWin) {
// scroll settings
this.scrollSettings = parent.parent.clientVars.scrollWhenFocusLineIsOutOfViewport;
// DOM reference
this.outerWin = outerWin;
this.doc = this.outerWin.document;
this.rootDocument = parent.parent.document;
}
2020-12-26 20:39:37 +00:00
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?
2020-12-26 20:39:37 +00:00
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);
}
}
2020-12-26 20:39:37 +00:00
};
2020-11-23 13:24:19 -05:00
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
2020-11-23 13:24:19 -05:00
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);
2020-11-23 13:24:19 -05:00
} else {
this.scrollNodeVerticallyIntoView(rep, innerHeight);
}
2020-11-23 13:24:19 -05:00
};
// 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.
2020-11-23 13:24:19 -05:00
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
2020-11-23 13:24:19 -05:00
const caretLine = rep.selStart[0];
const lineAfterCaretLine = caretLine + 1;
const firstLineVisibleAfterCaretLine = caretPosition.getNextVisibleLine(lineAfterCaretLine, rep);
2020-12-26 20:39:37 +00:00
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
2020-11-23 13:24:19 -05:00
const caretLinePosition = caretPosition.getPosition();
const viewportBottom = this._getViewPortTopBottom().bottom;
const nextLineBottom = caretPosition.getBottomOfNextBrowserLine(caretLinePosition, rep);
const nextLineIsBelowViewportBottom = nextLineBottom > viewportBottom;
return nextLineIsBelowViewportBottom;
}
return false;
2020-11-23 13:24:19 -05:00
};
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);
2020-11-23 13:24:19 -05:00
};
2020-11-23 13:24:19 -05:00
Scroll.prototype._getViewPortTopBottom = function () {
const theTop = this.getScrollY();
const doc = this.doc;
const height = doc.documentElement.clientHeight; // includes padding
2020-12-26 20:39:37 +00:00
// 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)
2020-12-26 20:39:37 +00:00
const viewportExtraSpacesAndPosition =
this._getEditorPositionTop() + this._getPaddingTopAddedWhenPageViewIsEnable();
return {
top: theTop,
2020-11-23 13:24:19 -05:00
bottom: (theTop + height - viewportExtraSpacesAndPosition),
};
2020-11-23 13:24:19 -05:00
};
2020-11-23 13:24:19 -05:00
Scroll.prototype._getEditorPositionTop = function () {
const editor = parent.document.getElementsByTagName('iframe');
const editorPositionTop = editor[0].offsetTop;
return editorPositionTop;
2020-11-23 13:24:19 -05:00
};
// ep_page_view adds padding-top, which makes the viewport smaller
2020-11-23 13:24:19 -05:00
Scroll.prototype._getPaddingTopAddedWhenPageViewIsEnable = function () {
const aceOuter = this.rootDocument.getElementsByName('ace_outer');
const aceOuterPaddingTop = parseInt($(aceOuter).css('padding-top'));
return aceOuterPaddingTop;
2020-11-23 13:24:19 -05:00
};
2020-11-23 13:24:19 -05:00
Scroll.prototype._getScrollXY = function () {
const win = this.outerWin;
const odoc = this.doc;
if (typeof (win.pageYOffset) === 'number') {
return {
x: win.pageXOffset,
2020-11-23 13:24:19 -05:00
y: win.pageYOffset,
};
}
2020-11-23 13:24:19 -05:00
const docel = odoc.documentElement;
if (docel && typeof (docel.scrollTop) === 'number') {
return {
x: docel.scrollLeft,
2020-11-23 13:24:19 -05:00
y: docel.scrollTop,
};
}
2020-11-23 13:24:19 -05:00
};
2020-11-23 13:24:19 -05:00
Scroll.prototype.getScrollX = function () {
return this._getScrollXY().x;
2020-11-23 13:24:19 -05:00
};
2020-11-23 13:24:19 -05:00
Scroll.prototype.getScrollY = function () {
return this._getScrollXY().y;
2020-11-23 13:24:19 -05:00
};
2020-11-23 13:24:19 -05:00
Scroll.prototype.setScrollX = function (x) {
this.outerWin.scrollTo(x, this.getScrollY());
2020-11-23 13:24:19 -05:00
};
2020-11-23 13:24:19 -05:00
Scroll.prototype.setScrollY = function (y) {
this.outerWin.scrollTo(this.getScrollX(), y);
2020-11-23 13:24:19 -05:00
};
2020-11-23 13:24:19 -05:00
Scroll.prototype.setScrollXY = function (x, y) {
this.outerWin.scrollTo(x, y);
2020-11-23 13:24:19 -05:00
};
Scroll.prototype._isCaretAtTheTopOfViewport = function (rep) {
const caretLine = rep.selStart[0];
const linePrevCaretLine = caretLine - 1;
2020-12-26 20:39:37 +00:00
const firstLineVisibleBeforeCaretLine =
caretPosition.getPreviousVisibleLine(linePrevCaretLine, rep);
const caretLineIsPartiallyVisibleOnViewport =
this._isLinePartiallyVisibleOnViewport(caretLine, rep);
const lineBeforeCaretLineIsPartiallyVisibleOnViewport =
this._isLinePartiallyVisibleOnViewport(firstLineVisibleBeforeCaretLine, rep);
if (caretLineIsPartiallyVisibleOnViewport || lineBeforeCaretLineIsPartiallyVisibleOnViewport) {
2020-11-23 13:24:19 -05:00
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;
2020-12-26 20:39:37 +00:00
const caretLineIsInsideOfViewport =
caretLineIsBelowViewportTop && caretLineIsAboveViewportBottom;
if (caretLineIsInsideOfViewport) {
2020-11-23 13:24:19 -05:00
const prevLineTop = caretPosition.getPositionTopOfPreviousBrowserLine(caretLinePosition, rep);
const previousLineIsAboveViewportTop = prevLineTop < viewportTop;
return previousLineIsAboveViewportTop;
}
}
return false;
2020-11-23 13:24:19 -05:00
};
// 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.
2020-12-26 20:39:37 +00:00
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
2020-11-23 13:24:19 -05:00
Scroll.prototype._getPercentageToScroll = function (aboveOfViewport) {
let percentageToScroll = this.scrollSettings.percentage.editionBelowViewport;
if (aboveOfViewport) {
percentageToScroll = this.scrollSettings.percentage.editionAboveViewport;
}
return percentageToScroll;
2020-11-23 13:24:19 -05:00
};
2020-11-23 13:24:19 -05:00
Scroll.prototype._getPixelsToScrollWhenUserPressesArrowUp = function (innerHeight) {
let pixels = 0;
const percentageToScrollUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp;
if (percentageToScrollUp > 0 && percentageToScrollUp <= 1) {
pixels = parseInt(innerHeight * percentageToScrollUp);
}
return pixels;
2020-11-23 13:24:19 -05:00
};
2020-11-23 13:24:19 -05:00
Scroll.prototype._scrollYPage = function (pixelsToScroll) {
const durationOfAnimationToShowFocusline = this.scrollSettings.duration;
if (durationOfAnimationToShowFocusline) {
this._scrollYPageWithAnimation(pixelsToScroll, durationOfAnimationToShowFocusline);
2020-11-23 13:24:19 -05:00
} else {
this._scrollYPageWithoutAnimation(pixelsToScroll);
}
2020-11-23 13:24:19 -05:00
};
2020-11-23 13:24:19 -05:00
Scroll.prototype._scrollYPageWithoutAnimation = function (pixelsToScroll) {
this.outerWin.scrollBy(0, pixelsToScroll);
2020-11-23 13:24:19 -05:00
};
2020-12-26 20:39:37 +00:00
Scroll.prototype._scrollYPageWithAnimation =
function (pixelsToScroll, durationOfAnimationToShowFocusline) {
const outerDocBody = this.doc.getElementById('outerdocbody');
2020-12-26 20:39:37 +00:00
// it works on later versions of Chrome
const $outerDocBody = $(outerDocBody);
this._triggerScrollWithAnimation(
$outerDocBody, pixelsToScroll, durationOfAnimationToShowFocusline);
2020-12-26 20:39:37 +00:00
// it works on Firefox and earlier versions of Chrome
const $outerDocBodyParent = $outerDocBody.parent();
this._triggerScrollWithAnimation(
$outerDocBodyParent, pixelsToScroll, durationOfAnimationToShowFocusline);
};
2020-12-26 20:39:37 +00:00
// 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, isPageUp, isPageDown) {
2020-11-23 13:24:19 -05:00
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.
2020-11-23 13:24:19 -05:00
const linePosition = caretPosition.getPosition();
if (isPageUp || isPageDown) {
// redraw entire page into view putting rep.selStart[0] at top left
const distanceOfTopOfViewport = linePosition.top - viewport.top;
const pixelsToScroll =
distanceOfTopOfViewport - this._getPixelsRelativeToPercentageOfViewport(innerHeight, true);
2020-12-28 09:34:58 +00:00
this._scrollYPage(pixelsToScroll - linePosition.height);
return;
}
2020-11-23 13:24:19 -05:00
};
2020-11-23 13:24:19 -05:00
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;
2020-11-23 13:24:19 -05:00
};
2020-11-23 13:24:19 -05:00
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;
2020-11-23 13:24:19 -05:00
};
2020-11-23 13:24:19 -05:00
Scroll.prototype._arrowUpWasPressedInTheFirstLineOfTheViewport = function (arrowUp, rep) {
const percentageScrollArrowUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp;
return percentageScrollArrowUp && arrowUp && this._isCaretAtTheTopOfViewport(rep);
2020-11-23 13:24:19 -05:00
};
Scroll.prototype.getVisibleLineRange = function (rep) {
const viewport = this._getViewPortTopBottom();
const obj = {};
const self = this;
const start = rep.lines.search((e) => self._getLineEntryTopBottom(e, obj).bottom > viewport.top);
2020-12-26 20:39:37 +00:00
// 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).bottom >= viewport.bottom);
if (end < start) end = start; // unlikely
return [start, end - 1];
2020-11-23 13:24:19 -05:00
};
2021-01-01 22:10:36 +00:00
Scroll.prototype.getPartiallyVisibleLineRange = function (rep) {
const viewport = this._getViewPortTopBottom();
const obj = {};
const self = this;
const start = rep.lines.search((e) => self._getLineEntryTopBottom(e, obj).top > viewport.top);
let end = rep.lines.search((e) => self._getLineEntryTopBottom(e, obj).top >= viewport.bottom);
if (end < start) end = start; // unlikely
return [start, end - 1];
};
2020-11-23 13:24:19 -05:00
Scroll.prototype.getVisibleCharRange = function (rep) {
const lineRange = this.getVisibleLineRange(rep);
// top.console.log('char range', 0, rep.lines.offsetOfIndex(lineRange[0]));
// top.console.log('char range', 1, rep.lines.offsetOfIndex(lineRange[1]));
return [rep.lines.offsetOfIndex(lineRange[0]), rep.lines.offsetOfIndex(lineRange[1])];
2020-11-23 13:24:19 -05:00
};
2021-01-02 14:55:19 +00:00
// moves viewport to next page
Scroll.prototype.movePage = function (direction) {
const viewport = this._getViewPortTopBottom();
// linePosition contains top and bottom, might be useful
// if the buffer of a fixed value isn't working as intended
const linePosition = caretPosition.getPosition();
const buffer = 25;
let pixelsToScroll = viewport.top - viewport.bottom;
if (direction === 'up') {
// buffer pixels unscrolled our safety net here. You can't use the current or previous
// line height because it might be a very long line..
pixelsToScroll = -Math.abs(pixelsToScroll + buffer);
} else {
pixelsToScroll = Math.abs(pixelsToScroll + buffer);
}
this.outerWin.scrollBy(0, pixelsToScroll);
return;
};
2021-01-02 16:30:32 +00:00
Scroll.prototype.getFirstVisibleCharacter = function (direction, rep) {
2021-01-02 14:55:19 +00:00
const viewport = this._getViewPortTopBottom();
console.log('viewport', viewport);
const editor = parent.document.getElementsByTagName('iframe');
const lines = $(editor).contents().find('div');
2021-01-02 16:30:32 +00:00
// const currentLine = $(editor).contents().find('#innerdocbody');
const currentLine = rep.lines.atIndex(rep.selEnd[0]);
console.log('currentLine', currentLine);
const modifiedRep = {};
modifiedRep.selStart = [];
modifiedRep.selEnd = [];
let willGoToNextLine = false;
// we have moved the viewport at this point, we want to know which
// line is visible?
2021-01-02 14:55:19 +00:00
$.each(lines, (index, line) => {
2021-01-02 16:30:32 +00:00
// Line height important for supporting long lines that fill viewport.
const lineBase = $(line).offset().top + $(line).height();
2021-01-02 14:55:19 +00:00
// is each line in the viewport?
2021-01-02 16:30:32 +00:00
if (lineBase > viewport.top) {
top.console.log('returning', index);
modifiedRep.selEnd[0] = index;
modifiedRep.selStart[0] = index;
modifiedRep.selEnd[1] = 0;
modifiedRep.selStart[1] = 0;
// Important for supporting long lines.
if (modifiedRep.selEnd[0] !== rep.selEnd[0]) willGoToNextLine = true;
return false; // exit $.each because we found a lovely line :)
2021-01-02 14:55:19 +00:00
}
});
2021-01-02 16:30:32 +00:00
if (willGoToNextLine) return modifiedRep;
// oh dear, looks like the original line is still the first in the viewport..
// we will need to move the rep X chars within that original position.
console.log('CANT SEE NEXT LiNE!');
modifiedRep.selStart[0] = rep.selStart[0];
modifiedRep.selEnd[0] = rep.selEnd[0];
const numberOfVisibleChars = this.getCountOfVisibleCharsInViewport(currentLine, viewport);
// TODO, figure out how many chars are visible in line.
modifiedRep.selStart[1] = rep.selStart[1] + numberOfVisibleChars;
modifiedRep.selEnd[1] = rep.selEnd[1] + numberOfVisibleChars;
return modifiedRep;
};
// line is a DOM Line
// returned is the number of characters in that index that are currently visible
// IE 120,240
Scroll.prototype.getCountOfVisibleCharsInViewport = (line, viewport) => {
const range = document.createRange();
const chars = line.text.split(''); // split "abc" into ["a","b","c"]
const parentElement = document.getElementById(line.domInfo.node.id).childNodes;
const charNumber = [];
// top.console.log(parentElement);
2021-01-03 12:36:04 +00:00
for (const node of parentElement) {
2021-01-02 16:30:32 +00:00
// each span..
// top.console.log('span', node); // shows all nodes from the collection
// top.console.log('span length', node.offsetTop); // shows all nodes from the collection
// each character
2021-01-03 12:36:04 +00:00
/*
2021-01-02 16:30:32 +00:00
let i = 0;
console.log(node);
2021-01-03 11:51:35 +00:00
if (!node || !node.childNodes) return;
2021-01-02 16:30:32 +00:00
node = node.childNodes[0];
2021-01-03 11:51:35 +00:00
if (!node) return; // temp patch to be removed.
2021-01-02 16:30:32 +00:00
if (node.childNodes && node.childNodes[1].length === 0) return;
console.log(node);
console.log(node.wholeText.length);
while (i < node.wholeText.length) {
// top.console.log(i, node.textContent[i]);
const range = document.createRange();
let failed = false;
try {
range.setStart(node, i);
} catch (e) {
failed = true;
console.log('fail', e);
// console.log('node', node);
}
try {
range.setEnd(node, i + 1);
} catch (e) {
failed = true;
console.log('fail', e);
console.log('node', node);
}
// console.log('range', range);
let char;
if (!failed) char = range.getClientRects();
console.log(node);
console.log('charr????', char);
if (char) return;
if (char && char.length && char[0]) {
const topOffset = char[0].y;
charNumber.push(topOffset);
// is this element in view?
console.log('topOffset', topOffset, 'viewport', viewport);
if (topOffset > viewport.top) {
console.log('can put rep here!', i);
return;
}
}
i++;
}
top.console.log('charNumber', charNumber);
2021-01-03 12:36:04 +00:00
*/
2021-01-02 16:30:32 +00:00
return; // TEMPJM CAKE remove once stable
}
return 1000;
2021-01-02 14:55:19 +00:00
};
2020-12-26 20:39:37 +00:00
exports.init = (outerWin) => new Scroll(outerWin);