summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--settings.json.template28
-rw-r--r--src/node/handler/PadMessageHandler.js9
-rw-r--r--src/node/utils/Settings.js27
-rw-r--r--src/static/js/ace2_inner.js229
-rw-r--r--src/static/js/caretPosition.js241
-rw-r--r--src/static/js/scroll.js366
-rw-r--r--tests/frontend/specs/scroll.js649
7 files changed, 1393 insertions, 156 deletions
diff --git a/settings.json.template b/settings.json.template
index 0cb10d50..699880bd 100644
--- a/settings.json.template
+++ b/settings.json.template
@@ -150,6 +150,34 @@
/* Time (in seconds) to automatically reconnect pad when a "Force reconnect"
message is shown to user. Set to 0 to disable automatic reconnection */
"automaticReconnectionTimeout" : 0,
+ /*
+ * By default, when caret is moved out of viewport, it scrolls the minimum height needed to make this
+ * line visible.
+ */
+ "scrollWhenFocusLineIsOutOfViewport": {
+ /*
+ * Percentage of viewport height to be additionally scrolled.
+ * E.g use "percentage.editionAboveViewport": 0.5, to place caret line in the
+ * middle of viewport, when user edits a line above of the viewport
+ * Set to 0 to disable extra scrolling
+ */
+ "percentage": {
+ "editionAboveViewport": 0,
+ "editionBelowViewport": 0
+ },
+ /* Time (in milliseconds) used to animate the scroll transition. Set to 0 to disable animation */
+ "duration": 0,
+ /*
+ * Flag to control if it should scroll when user places the caret in the last line of the viewport
+ */
+ "scrollWhenCaretIsInTheLastLineOfViewport": false,
+ /*
+ * Percentage of viewport height to be additionally scrolled when user presses arrow up
+ * in the line of the top of the viewport.
+ * Set to 0 to let the scroll to be handled as default by the Etherpad
+ */
+ "percentageToScrollWhenUserPressesArrowUp": 0
+ },
/* Users for basic authentication. is_admin = true gives access to /admin.
If you do not uncomment this, /admin will not be available! */
diff --git a/src/node/handler/PadMessageHandler.js b/src/node/handler/PadMessageHandler.js
index b7ec7cb2..060bca7b 100644
--- a/src/node/handler/PadMessageHandler.js
+++ b/src/node/handler/PadMessageHandler.js
@@ -1216,6 +1216,15 @@ function handleClientReady(client, message)
"parts": plugins.parts,
},
"indentationOnNewLine": settings.indentationOnNewLine,
+ "scrollWhenFocusLineIsOutOfViewport": {
+ "percentage" : {
+ "editionAboveViewport": settings.scrollWhenFocusLineIsOutOfViewport.percentage.editionAboveViewport,
+ "editionBelowViewport": settings.scrollWhenFocusLineIsOutOfViewport.percentage.editionBelowViewport,
+ },
+ "duration": settings.scrollWhenFocusLineIsOutOfViewport.duration,
+ "scrollWhenCaretIsInTheLastLineOfViewport": settings.scrollWhenFocusLineIsOutOfViewport.scrollWhenCaretIsInTheLastLineOfViewport,
+ "percentageToScrollWhenUserPressesArrowUp": settings.scrollWhenFocusLineIsOutOfViewport.percentageToScrollWhenUserPressesArrowUp,
+ },
"initialChangesets": [] // FIXME: REMOVE THIS SHIT
}
diff --git a/src/node/utils/Settings.js b/src/node/utils/Settings.js
index 660b7afb..cf7fea80 100644
--- a/src/node/utils/Settings.js
+++ b/src/node/utils/Settings.js
@@ -247,6 +247,33 @@ exports.users = {};
*/
exports.showSettingsInAdminPage = true;
+/*
+* By default, when caret is moved out of viewport, it scrolls the minimum height needed to make this
+* line visible.
+*/
+exports.scrollWhenFocusLineIsOutOfViewport = {
+ /*
+ * Percentage of viewport height to be additionally scrolled.
+ */
+ "percentage": {
+ "editionAboveViewport": 0,
+ "editionBelowViewport": 0
+ },
+ /*
+ * Time (in milliseconds) used to animate the scroll transition. Set to 0 to disable animation
+ */
+ "duration": 0,
+ /*
+ * Flag to control if it should scroll when user places the caret in the last line of the viewport
+ */
+ /*
+ * Percentage of viewport height to be additionally scrolled when user presses arrow up
+ * in the line of the top of the viewport.
+ */
+ "percentageToScrollWhenUserPressesArrowUp": 0,
+ "scrollWhenCaretIsInTheLastLineOfViewport": false
+};
+
//checks if abiword is avaiable
exports.abiwordAvailable = function()
{
diff --git a/src/static/js/ace2_inner.js b/src/static/js/ace2_inner.js
index 424bacf5..83f947ba 100644
--- a/src/static/js/ace2_inner.js
+++ b/src/static/js/ace2_inner.js
@@ -20,7 +20,6 @@
* limitations under the License.
*/
var _, $, jQuery, plugins, Ace2Common;
-
var browser = require('./browser');
if(browser.msie){
// Honestly fuck IE royally.
@@ -61,6 +60,7 @@ function Ace2Inner(){
var SkipList = require('./skiplist');
var undoModule = require('./undomodule').undoModule;
var AttributeManager = require('./AttributeManager');
+ var Scroll = require('./scroll');
var DEBUG = false; //$$ build script replaces the string "var DEBUG=true;//$$" with "var DEBUG=false;"
// changed to false
@@ -82,6 +82,7 @@ function Ace2Inner(){
var disposed = false;
var editorInfo = parent.editorInfo;
+
var iframe = window.frameElement;
var outerWin = iframe.ace_outerWin;
iframe.ace_outerWin = null; // prevent IE 6 memory leak
@@ -89,6 +90,8 @@ function Ace2Inner(){
var lineMetricsDiv = sideDiv.nextSibling;
initLineNumbers();
+ var scroll = Scroll.init(outerWin);
+
var outsideKeyDown = noop;
var outsideKeyPress = function(){return true;};
@@ -424,7 +427,7 @@ function Ace2Inner(){
var undoWorked = false;
try
{
- if (evt.eventType == "setup" || evt.eventType == "importText" || evt.eventType == "setBaseText")
+ if (isPadLoading(evt.eventType))
{
undoModule.clearHistory();
}
@@ -1208,7 +1211,7 @@ function Ace2Inner(){
updateLineNumbers(); // update line numbers if any time left
if (isTimeUp()) return;
- var visibleRange = getVisibleCharRange();
+ var visibleRange = scroll.getVisibleCharRange(rep);
var docRange = [0, rep.lines.totalWidth()];
//console.log("%o %o", docRange, visibleRange);
finishedImportantWork = true;
@@ -1670,7 +1673,7 @@ function Ace2Inner(){
});
//p.mark("relex");
- //rep.lexer.lexCharRange(getVisibleCharRange(), function() { return false; });
+ //rep.lexer.lexCharRange(scroll.getVisibleCharRange(rep), function() { return false; });
//var isTimeUp = newTimeLimit(100);
// do DOM inserts
p.mark("insert");
@@ -2914,6 +2917,15 @@ function Ace2Inner(){
documentAttributeManager: documentAttributeManager,
});
+ // we scroll when user places the caret at the last line of the pad
+ // when this settings is enabled
+ var docTextChanged = currentCallStack.docTextChanged;
+ if(!docTextChanged){
+ var isScrollableEvent = !isPadLoading(currentCallStack.type) && isScrollableEditEvent(currentCallStack.type);
+ var innerHeight = getInnerHeight();
+ scroll.scrollWhenCaretIsInTheLastLineOfViewportWhenNecessary(rep, isScrollableEvent, innerHeight);
+ }
+
return true;
//console.log("selStart: %o, selEnd: %o, focusAtStart: %s", rep.selStart, rep.selEnd,
//String(!!rep.selFocusAtStart));
@@ -2922,6 +2934,11 @@ function Ace2Inner(){
//console.log("%o %o %s", rep.selStart, rep.selEnd, rep.selFocusAtStart);
}
+ function isPadLoading(eventType)
+ {
+ return (eventType === 'setup') || (eventType === 'setBaseText') || (eventType === 'importText');
+ }
+
function doCreateDomLine(nonEmpty)
{
if (browser.msie && (!nonEmpty))
@@ -3277,50 +3294,36 @@ function Ace2Inner(){
return false;
}
- function getLineEntryTopBottom(entry, destObj)
- {
- var dom = entry.lineNode;
- var top = dom.offsetTop;
- var height = dom.offsetHeight;
- var obj = (destObj || {});
- obj.top = top;
- obj.bottom = (top + height);
- return obj;
- }
-
function getViewPortTopBottom()
{
- var theTop = getScrollY();
+ var theTop = scroll.getScrollY();
var doc = outerWin.document;
- var height = doc.documentElement.clientHeight;
+ var 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)
+ var viewportExtraSpacesAndPosition = getEditorPositionTop() + getPaddingTopAddedWhenPageViewIsEnable();
return {
top: theTop,
- bottom: (theTop + height)
+ bottom: (theTop + height - viewportExtraSpacesAndPosition)
};
}
- function getVisibleLineRange()
+
+ function getEditorPositionTop()
{
- var viewport = getViewPortTopBottom();
- //console.log("viewport top/bottom: %o", viewport);
- var obj = {};
- var start = rep.lines.search(function(e)
- {
- return getLineEntryTopBottom(e, obj).bottom > viewport.top;
- });
- var end = rep.lines.search(function(e)
- {
- return getLineEntryTopBottom(e, obj).top >= viewport.bottom;
- });
- if (end < start) end = start; // unlikely
- //console.log(start+","+end);
- return [start, end];
+ var editor = parent.document.getElementsByTagName('iframe');
+ var editorPositionTop = editor[0].offsetTop;
+ return editorPositionTop;
}
- function getVisibleCharRange()
+ // ep_page_view adds padding-top, which makes the viewport smaller
+ function getPaddingTopAddedWhenPageViewIsEnable()
{
- var lineRange = getVisibleLineRange();
- return [rep.lines.offsetOfIndex(lineRange[0]), rep.lines.offsetOfIndex(lineRange[1])];
+ var rootDocument = parent.parent.document;
+ var aceOuter = rootDocument.getElementsByName("ace_outer");
+ var aceOuterPaddingTop = parseInt($(aceOuter).css("padding-top"));
+ return aceOuterPaddingTop;
}
function handleCut(evt)
@@ -3966,12 +3969,12 @@ function Ace2Inner(){
doDeleteKey();
specialHandled = true;
}
- if((evt.which == 36 && evt.ctrlKey == true) && padShortcutEnabled.ctrlHome){ setScrollY(0); } // Control Home send to Y = 0
+ if((evt.which == 36 && evt.ctrlKey == true) && padShortcutEnabled.ctrlHome){ scroll.setScrollY(0); } // Control Home send to Y = 0
if((evt.which == 33 || evt.which == 34) && type == 'keydown' && !evt.ctrlKey){
evt.preventDefault(); // This is required, browsers will try to do normal default behavior on page up / down and the default behavior SUCKS
- var oldVisibleLineRange = getVisibleLineRange();
+ var oldVisibleLineRange = scroll.getVisibleLineRange(rep);
var topOffset = rep.selStart[0] - oldVisibleLineRange[0];
if(topOffset < 0 ){
topOffset = 0;
@@ -3981,7 +3984,7 @@ function Ace2Inner(){
var isPageUp = evt.which === 33;
scheduler.setTimeout(function(){
- var newVisibleLineRange = getVisibleLineRange(); // the visible lines IE 1,10
+ var newVisibleLineRange = scroll.getVisibleLineRange(rep); // the visible lines IE 1,10
var linesCount = rep.lines.length(); // total count of lines in pad IE 10
var numberOfLinesInViewport = newVisibleLineRange[1] - newVisibleLineRange[0]; // How many lines are in the viewport right now?
@@ -4014,56 +4017,26 @@ function Ace2Inner(){
// sometimes the first selection is -1 which causes problems (Especially with ep_page_view)
// so use focusNode.offsetTop value.
if(caretOffsetTop === -1) caretOffsetTop = myselection.focusNode.offsetTop;
- setScrollY(caretOffsetTop); // set the scrollY offset of the viewport on the document
+ scroll.setScrollY(caretOffsetTop); // set the scrollY offset of the viewport on the document
}, 200);
}
- /* Attempt to apply some sanity to cursor handling in Chrome after a copy / paste event
- We have to do this the way we do because rep. doesn't hold the value for keyheld events IE if the user
- presses and holds the arrow key .. Sorry if this is ugly, blame Chrome's weird handling of viewports after new content is added*/
- if((evt.which == 37 || evt.which == 38 || evt.which == 39 || evt.which == 40) && browser.chrome){
- var viewport = getViewPortTopBottom();
- var myselection = document.getSelection(); // get the current caret selection, can't use rep. here because that only gives us the start position not the current
- var caretOffsetTop = myselection.focusNode.parentNode.offsetTop || myselection.focusNode.offsetTop; // get the carets selection offset in px IE 214
- var lineHeight = $(myselection.focusNode.parentNode).parent("div").height(); // get the line height of the caret line
- // top.console.log("offsetTop", myselection.focusNode.parentNode.parentNode.offsetTop);
- try {
- lineHeight = $(myselection.focusNode).height() // needed for how chrome handles line heights of null objects
- // console.log("lineHeight now", lineHeight);
- }catch(e){}
- var caretOffsetTopBottom = caretOffsetTop + lineHeight;
- var visibleLineRange = getVisibleLineRange(); // the visible lines IE 1,10
-
- if(caretOffsetTop){ // sometimes caretOffsetTop bugs out and returns 0, not sure why, possible Chrome bug? Either way if it does we don't wanna mess with it
- // top.console.log(caretOffsetTop, viewport.top, caretOffsetTopBottom, viewport.bottom);
- var caretIsNotVisible = (caretOffsetTop < viewport.top || caretOffsetTopBottom >= viewport.bottom); // Is the Caret Visible to the user?
- // Expect some weird behavior caretOffsetTopBottom is greater than viewport.bottom on a keypress down
- var offsetTopSamePlace = caretOffsetTop == viewport.top; // sometimes moving key left & up leaves the caret at the same point as the viewport.top, technically the caret is visible but it's not fully visible so we should move to it
- if(offsetTopSamePlace && (evt.which == 37 || evt.which == 38)){
- var newY = caretOffsetTop;
- setScrollY(newY);
- }
- if(caretIsNotVisible){ // is the cursor no longer visible to the user?
- // top.console.log("Caret is NOT visible to the user");
- // top.console.log(caretOffsetTop,viewport.top,caretOffsetTopBottom,viewport.bottom);
- // Oh boy the caret is out of the visible area, I need to scroll the browser window to lineNum.
- if(evt.which == 37 || evt.which == 38){ // If left or up arrow
- var newY = caretOffsetTop; // That was easy!
- }
- if(evt.which == 39 || evt.which == 40){ // if down or right arrow
- // only move the viewport if we're at the bottom of the viewport, if we hit down any other time the viewport shouldn't change
- // NOTE: This behavior only fires if Chrome decides to break the page layout after a paste, it's annoying but nothing I can do
- var selection = getSelection();
- // top.console.log("line #", rep.selStart[0]); // the line our caret is on
- // top.console.log("firstvisible", visibleLineRange[0]); // the first visiblel ine
- // top.console.log("lastVisible", visibleLineRange[1]); // the last visible line
- // top.console.log(rep.selStart[0], visibleLineRange[1], rep.selStart[0], visibleLineRange[0]);
- var newY = viewport.top + lineHeight;
- }
- if(newY){
- setScrollY(newY); // set the scrollY offset of the viewport on the document
- }
+ // scroll to viewport when user presses arrow keys and caret is out of the viewport
+ if((evt.which == 37 || evt.which == 38 || evt.which == 39 || evt.which == 40)){
+ // we use arrowKeyWasReleased to avoid triggering the animation when a key is continuously pressed
+ // this makes the scroll smooth
+ if(!continuouslyPressingArrowKey(type)){
+ // We use getSelection() instead of rep to get the caret position. This avoids errors like when
+ // the caret position is not synchronized with the rep. For example, when an user presses arrow
+ // down to scroll the pad without releasing the key. When the key is released the rep is not
+ // synchronized, so we don't get the right node where caret is.
+ var selection = getSelection();
+
+ if(selection){
+ var arrowUp = evt.which === 38;
+ var innerHeight = getInnerHeight();
+ scroll.scrollWhenPressArrowKeys(arrowUp, rep, innerHeight);
}
}
}
@@ -4121,6 +4094,19 @@ function Ace2Inner(){
var thisKeyDoesntTriggerNormalize = false;
+ var arrowKeyWasReleased = true;
+ function continuouslyPressingArrowKey(type) {
+ var firstTimeKeyIsContinuouslyPressed = false;
+
+ if (type == 'keyup') arrowKeyWasReleased = true;
+ else if (type == 'keydown' && arrowKeyWasReleased) {
+ firstTimeKeyIsContinuouslyPressed = true;
+ arrowKeyWasReleased = false;
+ }
+
+ return !firstTimeKeyIsContinuouslyPressed;
+ }
+
function doUndoRedo(which)
{
// precond: normalized DOM
@@ -4837,9 +4823,6 @@ function Ace2Inner(){
setIfNecessary(root.style, "height", "");
}
}
- // if near edge, scroll to edge
- var scrollX = getScrollX();
- var scrollY = getScrollY();
var win = outerWin;
var r = 20;
@@ -4848,52 +4831,6 @@ function Ace2Inner(){
$(sideDiv).addClass('sidedivdelayed');
}
- function getScrollXY()
- {
- var win = outerWin;
- var odoc = outerWin.document;
- if (typeof(win.pageYOffset) == "number")
- {
- return {
- x: win.pageXOffset,
- y: win.pageYOffset
- };
- }
- var docel = odoc.documentElement;
- if (docel && typeof(docel.scrollTop) == "number")
- {
- return {
- x: docel.scrollLeft,
- y: docel.scrollTop
- };
- }
- }
-
- function getScrollX()
- {
- return getScrollXY().x;
- }
-
- function getScrollY()
- {
- return getScrollXY().y;
- }
-
- function setScrollX(x)
- {
- outerWin.scrollTo(x, getScrollY());
- }
-
- function setScrollY(y)
- {
- outerWin.scrollTo(getScrollX(), y);
- }
-
- function setScrollXY(x, y)
- {
- outerWin.scrollTo(x, y);
- }
-
var _teardownActions = [];
function teardown()
@@ -5214,26 +5151,6 @@ function Ace2Inner(){
return odoc.documentElement.clientWidth;
}
- function scrollNodeVerticallyIntoView(node)
- {
- // requires element (non-text) node;
- // if node extends above top of viewport or below bottom of viewport (or top of scrollbar),
- // scroll it the minimum distance needed to be completely in view.
- var win = outerWin;
- var odoc = outerWin.document;
- var distBelowTop = node.offsetTop + iframePadTop - win.scrollY;
- var distAboveBottom = win.scrollY + getInnerHeight() - (node.offsetTop + iframePadTop + node.offsetHeight);
-
- if (distBelowTop < 0)
- {
- win.scrollBy(0, distBelowTop);
- }
- else if (distAboveBottom < 0)
- {
- win.scrollBy(0, -distAboveBottom);
- }
- }
-
function scrollXHorizontallyIntoView(pixelX)
{
var win = outerWin;
@@ -5255,8 +5172,8 @@ function Ace2Inner(){
{
if (!rep.selStart) return;
fixView();
- var focusLine = (rep.selFocusAtStart ? rep.selStart[0] : rep.selEnd[0]);
- scrollNodeVerticallyIntoView(rep.lines.atIndex(focusLine).lineNode);
+ var innerHeight = getInnerHeight();
+ scroll.scrollNodeVerticallyIntoView(rep, innerHeight);
if (!doesWrap)
{
var browserSelection = getSelection();
diff --git a/src/static/js/caretPosition.js b/src/static/js/caretPosition.js
new file mode 100644
index 00000000..bc3fd007
--- /dev/null
+++ b/src/static/js/caretPosition.js
@@ -0,0 +1,241 @@
+// 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 = function ()
+{
+ var rect, line;
+ var editor = $('#innerdocbody')[0];
+ var range = getSelectionRange();
+ var isSelectionInsideTheEditor = range && $(range.endContainer).closest('body')[0].id === 'innerdocbody';
+
+ if(isSelectionInsideTheEditor){
+ // when we have the caret in an empty line, e.g. a line with only a <br>,
+ // getBoundingClientRect() returns all dimensions value as 0
+ var selectionIsInTheBeginningOfLine = range.endOffset > 0;
+ if (selectionIsInTheBeginningOfLine) {
+ var clonedRange = createSelectionRange(range);
+ line = getPositionOfElementOrSelection(clonedRange);
+ clonedRange.detach()
+ }
+
+ // when there's a <br> or any element that has no height, we can't get
+ // the dimension of the element where the caret is
+ if(!rect || rect.height === 0){
+ var clonedRange = createSelectionRange(range);
+
+ // as we can't get the element height, we create a text node to get the dimensions
+ // on the position
+ var shadowCaret = $(document.createTextNode("|"));
+ clonedRange.insertNode(shadowCaret[0]);
+ clonedRange.selectNode(shadowCaret[0]);
+
+ line = getPositionOfElementOrSelection(clonedRange);
+ clonedRange.detach()
+ shadowCaret.remove();
+ }
+ }
+ return line;
+}
+
+var createSelectionRange = function (range) {
+ clonedRange = range.cloneRange();
+
+ // we set the selection start and end to avoid error when user selects a text bigger than
+ // the viewport height and uses the arrow keys to expand the selection. In this particular
+ // case is necessary to know where the selections ends because both edges of the selection
+ // is out of the viewport but we only use the end of it to calculate if it needs to scroll
+ clonedRange.setStart(range.endContainer, range.endOffset);
+ clonedRange.setEnd(range.endContainer, range.endOffset);
+ return clonedRange;
+}
+
+var getPositionOfRepLineAtOffset = function (node, offset) {
+ // it is not a text node, so we cannot make a selection
+ if (node.tagName === 'BR' || node.tagName === 'EMPTY') {
+ return getPositionOfElementOrSelection(node);
+ }
+
+ while (node.length === 0 && node.nextSibling) {
+ node = node.nextSibling;
+ }
+
+ var newRange = new Range();
+ newRange.setStart(node, offset);
+ newRange.setEnd(node, offset);
+ var linePosition = getPositionOfElementOrSelection(newRange);
+ newRange.detach(); // performance sake
+ return linePosition;
+}
+
+function getPositionOfElementOrSelection(element) {
+ var rect = element.getBoundingClientRect();
+ var linePosition = {
+ bottom: rect.bottom,
+ height: rect.height,
+ top: rect.top
+ }
+ return linePosition;
+}
+
+// here we have two possibilities:
+// [1] the line before the caret line has the same type, so both of them has the same margin, padding
+// height, etc. So, we can use the caret line to make calculation necessary to know 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 = function(caretLinePosition, rep) {
+ var previousLineTop = caretLinePosition.top - caretLinePosition.height; // [1]
+ var isCaretLineFirstBrowserLine = caretLineIsFirstBrowserLine(caretLinePosition.top, rep);
+
+ // the caret is in the beginning of a rep line, so the previous browser line
+ // is the last line browser line of the a rep line
+ if (isCaretLineFirstBrowserLine) { //[2]
+ var lineBeforeCaretLine = rep.selStart[0] - 1;
+ var firstLineVisibleBeforeCaretLine = getPreviousVisibleLine(lineBeforeCaretLine, rep);
+ var linePosition = getDimensionOfLastBrowserLineOfRepLine(firstLineVisibleBeforeCaretLine, rep);
+ previousLineTop = linePosition.top;
+ }
+ return previousLineTop;
+}
+
+function caretLineIsFirstBrowserLine(caretLineTop, rep)
+{
+ var caretRepLine = rep.selStart[0];
+ var lineNode = rep.lines.atIndex(caretRepLine).lineNode;
+ var firstRootNode = getFirstRootChildNode(lineNode);
+
+ // to get the position of the node we get the position of the first char
+ var positionOfFirstRootNode = getPositionOfRepLineAtOffset(firstRootNode, 1);
+ return positionOfFirstRootNode.top === caretLineTop;
+}
+
+// find the first root node, usually it is a text node
+function getFirstRootChildNode(node)
+{
+ if(!node.firstChild){
+ return node;
+ }else{
+ return getFirstRootChildNode(node.firstChild);
+ }
+
+}
+
+function getPreviousVisibleLine(line, rep)
+{
+ if (line < 0) {
+ return 0;
+ }else if (isLineVisible(line, rep)) {
+ return line;
+ }else{
+ return getPreviousVisibleLine(line - 1, rep);
+ }
+}
+
+function getDimensionOfLastBrowserLineOfRepLine(line, rep)
+{
+ var lineNode = rep.lines.atIndex(line).lineNode;
+ var lastRootChildNode = getLastRootChildNode(lineNode);
+
+ // we get the position of the line in the last char of it
+ var lastRootChildNodePosition = getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length);
+ return lastRootChildNodePosition;
+}
+
+function getLastRootChildNode(node)
+{
+ if(!node.lastChild){
+ return {
+ node: node,
+ length: node.length
+ };
+ }else{
+ return getLastRootChildNode(node.lastChild);
+ }
+}
+
+// here we have two possibilities:
+// [1] The next line is part of the same rep line of the caret line, so we have the same dimensions.
+// 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 = function(caretLinePosition, rep)
+{
+ var nextLineBottom = caretLinePosition.bottom + caretLinePosition.height; //[1]
+ var isCaretLineLastBrowserLine = caretLineIsLastBrowserLineOfRepLine(caretLinePosition.top, rep);
+
+ // the caret is at the end of a rep line, so we can get the next browser line dimension
+ // using the position of the first char of the next rep line
+ if(isCaretLineLastBrowserLine){ //[2]
+ var nextLineAfterCaretLine = rep.selStart[0] + 1;
+ var firstNextLineVisibleAfterCaretLine = getNextVisibleLine(nextLineAfterCaretLine, rep);
+ var linePosition = getDimensionOfFirstBrowserLineOfRepLine(firstNextLineVisibleAfterCaretLine, rep);
+ nextLineBottom = linePosition.bottom;
+ }
+ return nextLineBottom;
+}
+
+function caretLineIsLastBrowserLineOfRepLine(caretLineTop, rep)
+{
+ var caretRepLine = rep.selStart[0];
+ var lineNode = rep.lines.atIndex(caretRepLine).lineNode;
+ var lastRootChildNode = getLastRootChildNode(lineNode);
+
+ // we take a rep line and get the position of the last char of it
+ var lastRootChildNodePosition = getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length);
+ return lastRootChildNodePosition.top === caretLineTop;
+}
+
+function getPreviousVisibleLine(line, rep)
+{
+ var firstLineOfPad = 0;
+ if (line <= firstLineOfPad) {
+ return firstLineOfPad;
+ }else if (isLineVisible(line,rep)) {
+ return line;
+ }else{
+ return getPreviousVisibleLine(line - 1, rep);
+ }
+}
+exports.getPreviousVisibleLine = getPreviousVisibleLine;
+
+function getNextVisibleLine(line, rep)
+{
+ var lastLineOfThePad = rep.lines.length() - 1;
+ if (line >= lastLineOfThePad) {
+ return lastLineOfThePad;
+ }else if (isLineVisible(line,rep)) {
+ return line;
+ }else{
+ return getNextVisibleLine(line + 1, rep);
+ }
+}
+exports.getNextVisibleLine = getNextVisibleLine;
+
+function isLineVisible(line, rep)
+{
+ return rep.lines.atIndex(line).lineNode.offsetHeight > 0;
+}
+
+function getDimensionOfFirstBrowserLineOfRepLine(line, rep)
+{
+ var lineNode = rep.lines.atIndex(line).lineNode;
+ var firstRootChildNode = getFirstRootChildNode(lineNode);
+
+ // we can get the position of the line, getting the position of the first char of the rep line
+ var firstRootChildNodePosition = getPositionOfRepLineAtOffset(firstRootChildNode, 1);
+ return firstRootChildNodePosition;
+}
+
+function getSelectionRange()
+{
+ var selection;
+ if (!window.getSelection) {
+ return;
+ }
+ selection = window.getSelection();
+ if (selection.rangeCount > 0) {
+ return selection.getRangeAt(0);
+ } else {
+ return null;
+ }
+}
diff --git a/src/static/js/scroll.js b/src/static/js/scroll.js
new file mode 100644
index 00000000..a53dc38c
--- /dev/null
+++ b/src/static/js/scroll.js
@@ -0,0 +1,366 @@
+/*
+ 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.
+*/
+var 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;
+}
+
+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?
+ var 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
+ var 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
+ var 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)){
+ var 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
+ var caretLine = rep.selStart[0];
+ var lineAfterCaretLine = caretLine + 1;
+ var firstLineVisibleAfterCaretLine = caretPosition.getNextVisibleLine(lineAfterCaretLine, rep);
+ var caretLineIsPartiallyVisibleOnViewport = this._isLinePartiallyVisibleOnViewport(caretLine, rep);
+ var lineAfterCaretLineIsPartiallyVisibleOnViewport = this._isLinePartiallyVisibleOnViewport(firstLineVisibleAfterCaretLine, rep);
+ if (caretLineIsPartiallyVisibleOnViewport || lineAfterCaretLineIsPartiallyVisibleOnViewport) {
+ // check if the caret is in the bottom of the viewport
+ var caretLinePosition = caretPosition.getPosition();
+ var viewportBottom = this._getViewPortTopBottom().bottom;
+ var nextLineBottom = caretPosition.getBottomOfNextBrowserLine(caretLinePosition, rep);
+ var nextLineIsBelowViewportBottom = nextLineBottom > viewportBottom;
+ return nextLineIsBelowViewportBottom;
+ }
+ return false;
+}
+
+Scroll.prototype._isLinePartiallyVisibleOnViewport = function(lineNumber, rep)
+{
+ var lineNode = rep.lines.atIndex(lineNumber);
+ var linePosition = this._getLineEntryTopBottom(lineNode);
+ var lineTop = linePosition.top;
+ var lineBottom = linePosition.bottom;
+ var viewport = this._getViewPortTopBottom();
+ var viewportBottom = viewport.bottom;
+ var viewportTop = viewport.top;
+
+ var topOfLineIsAboveOfViewportBottom = lineTop < viewportBottom;
+ var bottomOfLineIsOnOrBelowOfViewportBottom = lineBottom >= viewportBottom;
+ var topOfLineIsBelowViewportTop = lineTop >= viewportTop;
+ var topOfLineIsAboveViewportBottom = lineTop <= viewportBottom;
+ var bottomOfLineIsAboveViewportBottom = lineBottom <= viewportBottom;
+ var bottomOfLineIsBelowViewportTop = lineBottom >= viewportTop;
+
+ return (topOfLineIsAboveOfViewportBottom && bottomOfLineIsOnOrBelowOfViewportBottom) ||
+ (topOfLineIsBelowViewportTop && topOfLineIsAboveViewportBottom) ||
+ (bottomOfLineIsAboveViewportBottom && bottomOfLineIsBelowViewportTop);
+}
+
+Scroll.prototype._getViewPortTopBottom = function()
+{
+ var theTop = this.getScrollY();
+ var doc = this.doc;
+ var 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)
+ var viewportExtraSpacesAndPosition = this._getEditorPositionTop() + this._getPaddingTopAddedWhenPageViewIsEnable();
+ return {
+ top: theTop,
+ bottom: (theTop + height - viewportExtraSpacesAndPosition)
+ };
+}
+
+Scroll.prototype._getEditorPositionTop = function()
+{
+ var editor = parent.document.getElementsByTagName('iframe');
+ var editorPositionTop = editor[0].offsetTop;
+ return editorPositionTop;
+}
+
+// ep_page_view adds padding-top, which makes the viewport smaller
+Scroll.prototype._getPaddingTopAddedWhenPageViewIsEnable = function()
+{
+ var aceOuter = this.rootDocument.getElementsByName("ace_outer");
+ var aceOuterPaddingTop = parseInt($(aceOuter).css("padding-top"));
+ return aceOuterPaddingTop;
+}
+
+Scroll.prototype._getScrollXY = function()
+{
+ var win = this.outerWin;
+ var odoc = this.doc;
+ if (typeof(win.pageYOffset) == "number")
+ {
+ return {
+ x: win.pageXOffset,
+ y: win.pageYOffset
+ };
+ }
+ var 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)
+{
+ var caretLine = rep.selStart[0];
+ var linePrevCaretLine = caretLine - 1;
+ var firstLineVisibleBeforeCaretLine = caretPosition.getPreviousVisibleLine(linePrevCaretLine, rep);
+ var caretLineIsPartiallyVisibleOnViewport = this._isLinePartiallyVisibleOnViewport(caretLine, rep);
+ var lineBeforeCaretLineIsPartiallyVisibleOnViewport = this._isLinePartiallyVisibleOnViewport(firstLineVisibleBeforeCaretLine, rep);
+ if (caretLineIsPartiallyVisibleOnViewport || lineBeforeCaretLineIsPartiallyVisibleOnViewport) {
+ var caretLinePosition = caretPosition.getPosition(); // get the position of the browser line
+ var viewportPosition = this._getViewPortTopBottom();
+ var viewportTop = viewportPosition.top;
+ var viewportBottom = viewportPosition.bottom;
+ var caretLineIsBelowViewportTop = caretLinePosition.bottom >= viewportTop;
+ var caretLineIsAboveViewportBottom = caretLinePosition.top < viewportBottom;
+ var caretLineIsInsideOfViewport = caretLineIsBelowViewportTop && caretLineIsAboveViewportBottom;
+ if (caretLineIsInsideOfViewport) {
+ var prevLineTop = caretPosition.getPositionTopOfPreviousBrowserLine(caretLinePosition, rep);
+ var 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)
+{
+ var pixels = 0;
+ var 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)
+{
+ var percentageToScroll = this.scrollSettings.percentage.editionBelowViewport;
+ if(aboveOfViewport){
+ percentageToScroll = this.scrollSettings.percentage.editionAboveViewport;
+ }
+ return percentageToScroll;
+}
+
+Scroll.prototype._getPixelsToScrollWhenUserPressesArrowUp = function(innerHeight)
+{
+ var pixels = 0;
+ var percentageToScrollUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp;
+ if(percentageToScrollUp > 0 && percentageToScrollUp <= 1){
+ pixels = parseInt(innerHeight * percentageToScrollUp);
+ }
+ return pixels;
+}
+
+Scroll.prototype._scrollYPage = function(pixelsToScroll)
+{
+ var 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)
+{
+ var outerDocBody = this.doc.getElementById("outerdocbody");
+
+ // it works on later versions of Chrome
+ var $outerDocBody = $(outerDocBody);
+ this._triggerScrollWithAnimation($outerDocBody, pixelsToScroll, durationOfAnimationToShowFocusline);
+
+ // it works on Firefox and earlier versions of Chrome
+ var $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)
+{
+ var viewport = this._getViewPortTopBottom();
+ var isPartOfRepLineOutOfViewport = this._partOfRepLineIsOutOfViewport(viewport, rep);
+
+ // 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.
+ var linePosition = caretPosition.getPosition();
+ if(linePosition){
+ var distanceOfTopOfViewport = linePosition.top - viewport.top;
+ var distanceOfBottomOfViewport = viewport.bottom - linePosition.bottom;
+ var caretIsAboveOfViewport = distanceOfTopOfViewport < 0;
+ var caretIsBelowOfViewport = distanceOfBottomOfViewport < 0;
+ if(caretIsAboveOfViewport){
+ var pixelsToScroll = distanceOfTopOfViewport - this._getPixelsRelativeToPercentageOfViewport(innerHeight, true);
+ this._scrollYPage(pixelsToScroll);
+ }else if(caretIsBelowOfViewport){
+ var pixelsToScroll = -distanceOfBottomOfViewport + this._getPixelsRelativeToPercentageOfViewport(innerHeight);
+ this._scrollYPage(pixelsToScroll);
+ }else{
+ this.scrollWhenCaretIsInTheLastLineOfViewportWhenNecessary(rep, true, innerHeight);
+ }
+ }
+}
+
+Scroll.prototype._partOfRepLineIsOutOfViewport = function(viewportPosition, rep)
+{
+ var focusLine = (rep.selFocusAtStart ? rep.selStart[0] : rep.selEnd[0]);
+ var line = rep.lines.atIndex(focusLine);
+ var linePosition = this._getLineEntryTopBottom(line);
+ var lineIsAboveOfViewport = linePosition.top < viewportPosition.top;
+ var lineIsBelowOfViewport = linePosition.bottom > viewportPosition.bottom;
+
+ return lineIsBelowOfViewport || lineIsAboveOfViewport;
+}
+
+Scroll.prototype._getLineEntryTopBottom = function(entry, destObj)
+{
+ var dom = entry.lineNode;
+ var top = dom.offsetTop;
+ var height = dom.offsetHeight;
+ var obj = (destObj || {});
+ obj.top = top;
+ obj.bottom = (top + height);
+ return obj;
+}
+
+Scroll.prototype._arrowUpWasPressedInTheFirstLineOfTheViewport = function(arrowUp, rep)
+{
+ var percentageScrollArrowUp = this.scrollSettings.percentageToScrollWhenUserPressesArrowUp;
+ return percentageScrollArrowUp && arrowUp && this._isCaretAtTheTopOfViewport(rep);
+}
+
+Scroll.prototype.getVisibleLineRange = function(rep)
+{
+ var viewport = this._getViewPortTopBottom();
+ //console.log("viewport top/bottom: %o", viewport);
+ var obj = {};
+ var self = this;
+ var start = rep.lines.search(function(e)
+ {
+ return self._getLineEntryTopBottom(e, obj).bottom > viewport.top;
+ });
+ var end = rep.lines.search(function(e)
+ {
+ // 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.
+ return 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)
+{
+ var lineRange = this.getVisibleLineRange(rep);
+ return [rep.lines.offsetOfIndex(lineRange[0]), rep.lines.offsetOfIndex(lineRange[1])];
+}
+
+exports.init = function(outerWin)
+{
+ return new Scroll(outerWin);
+}
diff --git a/tests/frontend/specs/scroll.js b/tests/frontend/specs/scroll.js
new file mode 100644
index 00000000..096b06b6
--- /dev/null
+++ b/tests/frontend/specs/scroll.js
@@ -0,0 +1,649 @@
+describe('scroll when focus line is out of viewport', function () {
+ before(function (done) {
+ helper.newPad(function(){
+ cleanPad(function(){
+ forceUseMonospacedFont();
+ scrollWhenPlaceCaretInTheLastLineOfViewport();
+ createPadWithSeveralLines(function(){
+ resizeEditorHeight();
+ done();
+ });
+ });
+ });
+ this.timeout(20000);
+ });
+
+ context('when user presses any arrow keys on a line above the viewport', function(){
+ context('and scroll percentage config is set to 0.2 on settings.json', function(){
+ var lineCloseOfTopOfPad = 10;
+ before(function (done) {
+ setScrollPercentageWhenFocusLineIsOutOfViewport(0.2, true);
+ scrollEditorToBottomOfPad();
+
+ placeCaretInTheBeginningOfLine(lineCloseOfTopOfPad, function(){ // place caret in the 10th line
+ // warning: even pressing right arrow, the caret does not change of position
+ // the column where the caret is, it has not importance, only the line
+ pressAndReleaseRightArrow();
+ done();
+ });
+ });
+
+ it('keeps the focus line scrolled 20% from the top of the viewport', function (done) {
+ // default behavior is to put the line in the top of viewport, but as
+ // scrollPercentageWhenFocusLineIsOutOfViewport is set to 0.2, we have an extra 20% of lines scrolled
+ // (2 lines, which are the 20% of the 10 that are visible on viewport)
+ var firstLineOfViewport = getFirstLineVisibileOfViewport();
+ expect(lineCloseOfTopOfPad).to.be(firstLineOfViewport + 2);
+ done();
+ });
+ });
+ });
+
+ context('when user presses any arrow keys on a line below the viewport', function(){
+ context('and scroll percentage config is set to 0.7 on settings.json', function(){
+ var lineCloseToBottomOfPad = 50;
+ before(function (done) {
+ setScrollPercentageWhenFocusLineIsOutOfViewport(0.7);
+
+ // firstly, scroll to make the lineCloseToBottomOfPad visible. After that, scroll to make it out of viewport
+ scrollEditorToTopOfPad();
+ placeCaretAtTheEndOfLine(lineCloseToBottomOfPad); // place caret in the 50th line
+ setTimeout(function() {
+ // warning: even pressing right arrow, the caret does not change of position
+ pressAndReleaseLeftArrow();
+ done();
+ }, 1000);
+ });
+
+ it('keeps the focus line scrolled 70% from the bottom of the viewport', function (done) {
+ // default behavior is to put the line in the top of viewport, but as
+ // scrollPercentageWhenFocusLineIsOutOfViewport is set to 0.7, we have an extra 70% of lines scrolled
+ // (7 lines, which are the 70% of the 10 that are visible on viewport)
+ var lastLineOfViewport = getLastLineVisibleOfViewport();
+ expect(lineCloseToBottomOfPad).to.be(lastLineOfViewport - 7);
+ done();
+ });
+ });
+ });
+
+ context('when user presses arrow up on the first line of the viewport', function(){
+ context('and percentageToScrollWhenUserPressesArrowUp is set to 0.3', function () {
+ var lineOnTopOfViewportWhenThePadIsScrolledDown;
+ before(function (done) {
+ setPercentageToScrollWhenUserPressesArrowUp(0.3);
+
+ // we need some room to make the scroll up
+ scrollEditorToBottomOfPad();
+ lineOnTopOfViewportWhenThePadIsScrolledDown = 91;
+ placeCaretAtTheEndOfLine(lineOnTopOfViewportWhenThePadIsScrolledDown);
+ setTimeout(function() {
+ // warning: even pressing up arrow, the caret does not change of position
+ pressAndReleaseUpArrow();
+ done();
+ }, 1000);
+ });
+
+ it('keeps the focus line scrolled 30% of the top of the viewport', function (done) {
+ // default behavior is to put the line in the top of viewport, but as
+ // PercentageToScrollWhenUserPressesArrowUp is set to 0.3, we have an extra 30% of lines scrolled
+ // (3 lines, which are the 30% of the 10 that are visible on viewport)
+ var firstLineOfViewport = getFirstLineVisibileOfViewport();
+ expect(firstLineOfViewport).to.be(lineOnTopOfViewportWhenThePadIsScrolledDown - 3);
+ done();
+ })
+ });
+ });
+
+ context('when user edits the last line of viewport', function(){
+ context('and scroll percentage config is set to 0 on settings.json', function(){
+ var lastLineOfViewportBeforeEnter = 10;
+ before(function () {
+ // the default value
+ resetScrollPercentageWhenFocusLineIsOutOfViewport();
+
+ // make sure the last line on viewport is the 10th one
+ scrollEditorToTopOfPad();
+ placeCaretAtTheEndOfLine(lastLineOfViewportBeforeEnter);
+ pressEnter();
+ });
+
+ it('keeps the focus line on the bottom of the viewport', function (done) {
+ var lastLineOfViewportAfterEnter = getLastLineVisibleOfViewport();
+ expect(lastLineOfViewportAfterEnter).to.be(lastLineOfViewportBeforeEnter + 1);
+ done();
+ });
+ });
+
+ context('and scrollPercentageWhenFocusLineIsOutOfViewport is set to 0.3', function(){ // this value is arbitrary
+ var lastLineOfViewportBeforeEnter = 9;
+ before(function () {
+ setScrollPercentageWhenFocusLineIsOutOfViewport(0.3);
+
+ // make sure the last line on viewport is the 10th one
+ scrollEditorToTopOfPad();
+ placeCaretAtTheEndOfLine(lastLineOfViewportBeforeEnter);
+ pressBackspace();
+ });
+
+ it('scrolls 30% of viewport up', function (done) {
+ var lastLineOfViewportAfterEnter = getLastLineVisibleOfViewport();
+ // default behavior is to scroll one line at the bottom of viewport, but as
+ // scrollPercentageWhenFocusLineIsOutOfViewport is set to 0.3, we have an extra 30% of lines scrolled
+ // (3 lines, which are the 30% of the 10 that are visible on viewport)
+ expect(lastLineOfViewportAfterEnter).to.be(lastLineOfViewportBeforeEnter + 3);
+ done();
+ });
+ });
+
+ context('and it is set to a value that overflow the interval [0, 1]', function(){
+ var lastLineOfViewportBeforeEnter = 10;
+ before(function(){
+ var scrollPercentageWhenFocusLineIsOutOfViewport = 1.5;
+ scrollEditorToTopOfPad();
+ placeCaretAtTheEndOfLine(lastLineOfViewportBeforeEnter);
+ setScrollPercentageWhenFocusLineIsOutOfViewport(scrollPercentageWhenFocusLineIsOutOfViewport);
+ pressEnter();
+ });
+
+ it('keeps the default behavior of moving the focus line on the bottom of the viewport', function (done) {
+ var lastLineOfViewportAfterEnter = getLastLineVisibleOfViewport();
+ expect(lastLineOfViewportAfterEnter).to.be(lastLineOfViewportBeforeEnter + 1);
+ done();
+ });
+ });
+ });
+
+ context('when user edits a line above the viewport', function(){
+ context('and scroll percentage config is set to 0 on settings.json', function(){
+ var lineCloseOfTopOfPad = 10;
+ before(function () {
+ // the default value
+ setScrollPercentageWhenFocusLineIsOutOfViewport(0);
+
+ // firstly, scroll to make the lineCloseOfTopOfPad visible. After that, scroll to make it out of viewport
+ scrollEditorToTopOfPad();
+ placeCaretAtTheEndOfLine(lineCloseOfTopOfPad); // place caret in the 10th line
+ scrollEditorToBottomOfPad();
+ pressBackspace(); // edit the line where the caret is, which is above the viewport
+ });
+
+ it('keeps the focus line on the top of the viewport', function (done) {
+ var firstLineOfViewportAfterEnter = getFirstLineVisibileOfViewport();
+ expect(firstLineOfViewportAfterEnter).to.be(lineCloseOfTopOfPad);
+ done();
+ });
+ });
+
+ context('and scrollPercentageWhenFocusLineIsOutOfViewport is set to 0.2', function(){ // this value is arbitrary
+ var lineCloseToBottomOfPad = 50;
+ before(function () {
+ // we force the line edited to be above the top of the viewport
+ setScrollPercentageWhenFocusLineIsOutOfViewport(0.2, true); // set scroll jump to 20%
+ scrollEditorToTopOfPad();
+ placeCaretAtTheEndOfLine(lineCloseToBottomOfPad);
+ scrollEditorToBottomOfPad();
+ pressBackspace(); // edit line
+ });
+
+ it('scrolls 20% of viewport down', function (done) {
+ // default behavior is to scroll one line at the top of viewport, but as
+ // scrollPercentageWhenFocusLineIsOutOfViewport is set to 0.2, we have an extra 20% of lines scrolled
+ // (2 lines, which are the 20% of the 10 that are visible on viewport)
+ var firstLineVisibileOfViewport = getFirstLineVisibileOfViewport();
+ expect(lineCloseToBottomOfPad).to.be(firstLineVisibileOfViewport + 2);
+ done();
+ });
+ });
+ });
+
+ context('when user places the caret at the last line visible of viewport', function(){
+ var lastLineVisible;
+ context('and scroll percentage config is set to 0 on settings.json', function(){
+ before(function (done) {
+ // reset to the default value
+ resetScrollPercentageWhenFocusLineIsOutOfViewport();
+
+ placeCaretInTheBeginningOfLine(0, function(){ // reset caret position
+ scrollEditorToTopOfPad();
+ lastLineVisible = getLastLineVisibleOfViewport();
+ placeCaretInTheBeginningOfLine(lastLineVisible, done); // place caret in the 9th line
+ });
+
+ });
+
+ it('does not scroll', function(done){
+ setTimeout(function() {
+ var lastLineOfViewport = getLastLineVisibleOfViewport();
+ var lineDoesNotScroll = lastLineOfViewport === lastLineVisible;
+ expect(lineDoesNotScroll).to.be(true);
+ done();
+ }, 1000);
+ });
+ });
+ context('and scroll percentage config is set to 0.5 on settings.json', function(){
+ before(function (done) {
+ setScrollPercentageWhenFocusLineIsOutOfViewport(0.5);
+ scrollEditorToTopOfPad();
+ placeCaretInTheBeginningOfLine(0, function(){ // reset caret position
+ // this timeout inside a callback is ugly but it necessary to give time to aceSelectionChange
+ // realizes that the selection has been changed
+ setTimeout(function() {
+ lastLineVisible = getLastLineVisibleOfViewport();
+ placeCaretInTheBeginningOfLine(lastLineVisible, done); // place caret in the 9th line
+ }, 1000);
+ });
+ });
+
+ it('scrolls line to 50% of the viewport', function(done){
+ helper.waitFor(function(){
+ var lastLineOfViewport = getLastLineVisibleOfViewport();
+ var lastLinesScrolledFiveLinesUp = lastLineOfViewport - 5 === lastLineVisible;
+ return lastLinesScrolledFiveLinesUp;
+ }).done(done);
+ });
+ });
+ });
+
+ // This is a special case. When user is selecting a text with arrow down or arrow left we have
+ // to keep the last line selected on focus
+ context('when the first line selected is out of the viewport and user presses shift arrow down', function(){
+ var lastLineOfPad = 99;
+ before(function (done) {
+ scrollEditorToTopOfPad();
+
+ // make a selection bigger than the viewport height
+ var $firstLineOfSelection = getLine(0);
+ var $lastLineOfSelection = getLine(lastLineOfPad);
+ var lengthOfLastLine = $lastLineOfSelection.text().length;
+ helper.selectLines($firstLineOfSelection, $lastLineOfSelection, 0, lengthOfLastLine);
+
+ // place the last line selected on the viewport
+ scrollEditorToBottomOfPad();
+
+ // press a key to make the selection goes down
+ // although we can't simulate the extending of selection. It's possible to send a key event
+ // which is captured on ace2_inner scroll function.
+ pressAndReleaseLeftArrow(true);
+ done();
+ });
+
+ it('keeps the last line selected on focus', function (done) {
+ var lastLineOfSelectionIsVisible = isLineOnViewport(lastLineOfPad);
+ expect(lastLineOfSelectionIsVisible).to.be(true);
+ done();
+ });
+ });
+
+ // In this scenario we avoid the bouncing scroll. E.g Let's suppose we have a big line that is
+ // the size of the viewport, and its top is above the viewport. When user presses '<-', this line
+ // will scroll down because the top is out of the viewport. When it scrolls down, the bottom of
+ // line gets below the viewport so when user presses '<-' again it scrolls up to make the bottom
+ // of line visible. If user presses arrow keys more than one time, the editor will keep scrolling up and down
+ context('when the line height is bigger than the scroll amount percentage * viewport height', function(){
+ var scrollOfEditorBeforePressKey;
+ var BIG_LINE_NUMBER = 0;
+ var MIDDLE_OF_BIG_LINE = 51;
+ before(function (done) {
+ createPadWithALineHigherThanViewportHeight(this, BIG_LINE_NUMBER, function(){
+ setScrollPercentageWhenFocusLineIsOutOfViewport(0.5); // set any value to force scroll to outside to viewport
+ var $bigLine = getLine(BIG_LINE_NUMBER);
+
+ // each line has about 5 chars, we place the caret in the middle of the line
+ helper.selectLines($bigLine, $bigLine, MIDDLE_OF_BIG_LINE, MIDDLE_OF_BIG_LINE);
+
+ scrollEditorToLeaveTopAndBottomOfBigLineOutOfViewport($bigLine);
+ scrollOfEditorBeforePressKey = getEditorScroll();
+
+ // press a key to force to scroll
+ pressAndReleaseRightArrow();
+ done();
+ });
+ });
+
+ // reset pad to the original text
+ after(function (done) {
+ this.timeout(5000);
+ cleanPad(function(){
+ createPadWithSeveralLines(function(){
+ resetEditorWidth();
+ done();
+ });
+ });
+ });
+
+ // as the editor.line is inside of the viewport, it should not scroll
+ it('should not scroll', function (done) {
+ var scrollOfEditorAfterPressKey = getEditorScroll();
+ expect(scrollOfEditorAfterPressKey).to.be(scrollOfEditorBeforePressKey);
+ done();
+ });
+ });
+
+ // Some plugins, for example the ep_page_view, change the editor dimensions. This plugin, for example,
+ // adds padding-top to the ace_outer, which changes the viewport height
+ describe('integration with plugins which changes the margin of editor', function(){
+ context('when editor dimensions changes', function(){
+ before(function () {
+ // reset the size of editor. Now we show more than 10 lines as in the other tests
+ resetResizeOfEditorHeight();
+ scrollEditorToTopOfPad();
+
+ // height of the editor viewport
+ var editorHeight = getEditorHeight();
+
+ // add a big padding-top, 50% of the viewport
+ var paddingTopOfAceOuter = editorHeight/2;
+ var chrome$ = helper.padChrome$;
+ var $outerIframe = chrome$('iframe');
+ $outerIframe.css('padding-top', paddingTopOfAceOuter);
+
+ // we set a big value to check if the scroll is made
+ setScrollPercentageWhenFocusLineIsOutOfViewport(1);
+ });
+
+ context('and user places the caret in the last line visible of the pad', function(){
+ var lastLineVisible;
+ beforeEach(function (done) {
+ lastLineVisible = getLastLineVisibleOfViewport();
+ placeCaretInTheBeginningOfLine(lastLineVisible, done);
+ });
+
+ it('scrolls the line where caret is', function(done){
+ helper.waitFor(function(){
+ var firstLineVisibileOfViewport = getFirstLineVisibileOfViewport();
+ var linesScrolled = firstLineVisibileOfViewport !== 0;
+ return linesScrolled;
+ }).done(done);
+ });
+ });
+ });
+ });
+
+ /* ********************* Helper functions/constants ********************* */
+ var TOP_OF_PAGE = 0;
+ var BOTTOM_OF_PAGE = 5000; // we use a big value to force the page to be scrolled all the way down
+ var LINES_OF_PAD = 100;
+ var ENTER = 13;
+ var BACKSPACE = 8;
+ var LEFT_ARROW = 37;
+ var UP_ARROW = 38;
+ var RIGHT_ARROW = 39;
+ var LINES_ON_VIEWPORT = 10;
+ var WIDTH_OF_EDITOR_RESIZED = 100;
+ var LONG_TEXT_CHARS = 100;
+
+ var cleanPad = function(callback) {
+ var inner$ = helper.padInner$;
+ var $padContent = inner$('#innerdocbody');
+ $padContent.html('');
+
+ // wait for Etherpad to re-create first line
+ helper.waitFor(function(){
+ var lineNumber = inner$('div').length;
+ return lineNumber === 1;
+ }, 2000).done(callback);
+ };
+
+ var createPadWithSeveralLines = function(done) {
+ var line = '<span>a</span><br>';
+ var $firstLine = helper.padInner$('div').first();
+ var lines = line.repeat(LINES_OF_PAD); //arbitrary number, we need to create lines that is over the viewport
+ $firstLine.html(lines);
+
+ helper.waitFor(function(){
+ var linesCreated = helper.padInner$('div').length;
+ return linesCreated === LINES_OF_PAD;
+ }, 4000).done(done);
+ };
+
+ var createPadWithALineHigherThanViewportHeight = function(test, line, done) {
+ var viewportHeight = 160; //10 lines * 16px (height of line)
+ test.timeout(5000);
+ cleanPad(function(){
+ // make the editor smaller to make test easier
+ // with that width the each line has about 5 chars
+ resizeEditorWidth();
+
+ // we create a line with 100 chars, which makes about 20 lines
+ setLongTextOnLine(line);
+ helper.waitFor(function () {
+ var $firstLine = getLine(line);
+
+ var heightOfLine = $firstLine.get(0).getBoundingClientRect().height;
+ return heightOfLine >= viewportHeight;
+ }, 4000).done(done);
+ });
+ };
+
+ var setLongTextOnLine = function(line) {
+ var $line = getLine(line);
+ var longText = 'a'.repeat(LONG_TEXT_CHARS);
+ $line.html(longText);
+ };
+
+ // resize the editor to make the tests easier
+ var resizeEditorHeight = function() {
+ var chrome$ = helper.padChrome$;
+ chrome$('#editorcontainer').css('height', getSizeOfViewport());
+ };
+
+ // this makes about 5 chars per line
+ var resizeEditorWidth = function() {
+ var chrome$ = helper.padChrome$;
+ chrome$('#editorcontainer').css('width', WIDTH_OF_EDITOR_RESIZED);
+ };
+
+ var resetResizeOfEditorHeight = function() {
+ var chrome$ = helper.padChrome$;
+ chrome$('#editorcontainer').css('height', '');
+ };
+
+ var resetEditorWidth = function () {
+ var chrome$ = helper.padChrome$;
+ chrome$('#editorcontainer').css('width', '');
+ };
+
+ var getEditorHeight = function() {
+ var chrome$ = helper.padChrome$;
+ var $editor = chrome$('#editorcontainer');
+ var editorHeight = $editor.get(0).clientHeight;
+ return editorHeight;
+ };
+
+ var getSizeOfViewport = function() {
+ return getLinePositionOnViewport(LINES_ON_VIEWPORT) - getLinePositionOnViewport(0);
+ };
+
+ var scrollPageTo = function(value) {
+ var outer$ = helper.padOuter$;
+ var $ace_outer = outer$('#outerdocbody').parent();
+ $ace_outer.parent().scrollTop(value);
+ };
+
+ var scrollEditorToTopOfPad = function() {
+ scrollPageTo(TOP_OF_PAGE);
+ };
+
+ var scrollEditorToBottomOfPad = function() {
+ scrollPageTo(BOTTOM_OF_PAGE);
+ };
+
+ var scrollEditorToLeaveTopAndBottomOfBigLineOutOfViewport = function ($bigLine) {
+ var lineHeight = $bigLine.get(0).getBoundingClientRect().height;
+ var middleOfLine = lineHeight/2;
+ scrollPageTo(middleOfLine);
+ };
+
+ var getLine = function(lineNum) {
+ var inner$ = helper.padInner$;
+ var $line = inner$('div').eq(lineNum);
+ return $line;
+ };
+
+ var placeCaretAtTheEndOfLine = function(lineNum) {
+ var $targetLine = getLine(lineNum);
+ var lineLength = $targetLine.text().length;
+ helper.selectLines($targetLine, $targetLine, lineLength, lineLength);
+ };
+
+ var placeCaretInTheBeginningOfLine = function(lineNum, cb) {
+ var $targetLine = getLine(lineNum);
+ helper.selectLines($targetLine, $targetLine, 0, 0);
+ helper.waitFor(function() {
+ var $lineWhereCaretIs = getLineWhereCaretIs();
+ return $targetLine.get(0) === $lineWhereCaretIs.get(0);
+ }).done(cb);
+ };
+
+ var getLineWhereCaretIs = function() {
+ var inner$ = helper.padInner$;
+ var nodeWhereCaretIs = inner$.document.getSelection().anchorNode;
+ var $lineWhereCaretIs = $(nodeWhereCaretIs).closest('div');
+ return $lineWhereCaretIs;
+ };
+
+ var getFirstLineVisibileOfViewport = function() {
+ return _.find(_.range(0, LINES_OF_PAD - 1), isLineOnViewport);
+ };
+
+ var getLastLineVisibleOfViewport = function() {
+ return _.find(_.range(LINES_OF_PAD - 1, 0, -1), isLineOnViewport);
+ };
+
+ var pressKey = function(keyCode, shiftIsPressed){
+ var inner$ = helper.padInner$;
+ var evtType;
+ if(inner$(window)[0].bowser.firefox || inner$(window)[0].bowser.modernIE){ // if it's a mozilla or IE
+ evtType = 'keypress';
+ }else{
+ evtType = 'keydown';
+ }
+ var e = inner$.Event(evtType);
+ e.shiftKey = shiftIsPressed;
+ e.keyCode = keyCode;
+ e.which = keyCode; // etherpad listens to 'which'
+ inner$('#innerdocbody').trigger(e);
+ };
+
+ var releaseKey = function(keyCode){
+ var inner$ = helper.padInner$;
+ var evtType = 'keyup';
+ var e = inner$.Event(evtType);
+ e.keyCode = keyCode;
+ e.which = keyCode; // etherpad listens to 'which'
+ inner$('#innerdocbody').trigger(e);
+ };
+
+ var pressEnter = function() {
+ pressKey(ENTER);
+ };
+
+ var pressBackspace = function() {
+ pressKey(BACKSPACE);
+ };
+
+ var pressAndReleaseUpArrow = function() {
+ pressKey(UP_ARROW);
+ releaseKey(UP_ARROW);
+ };
+
+ var pressAndReleaseRightArrow = function() {
+ pressKey(RIGHT_ARROW);
+ releaseKey(RIGHT_ARROW);
+ };
+
+ var pressAndReleaseLeftArrow = function(shiftIsPressed) {
+ pressKey(LEFT_ARROW, shiftIsPressed);
+ releaseKey(LEFT_ARROW);
+ };
+
+ var isLineOnViewport = function(lineNumber) {
+ // in the function scrollNodeVerticallyIntoView from ace2_inner.js, iframePadTop is used to calculate
+ // how much scroll is needed. Although the name refers to padding-top, this value is not set on the
+ // padding-top.
+ var iframePadTop = 8;
+ var $line = getLine(lineNumber);
+ var linePosition = $line.get(0).getBoundingClientRect();
+
+ // position relative to the current viewport
+ var linePositionTopOnViewport = linePosition.top - getEditorScroll() + iframePadTop;
+ var linePositionBottomOnViewport = linePosition.bottom - getEditorScroll();
+
+ var lineBellowTop = linePositionBottomOnViewport > 0;
+ var lineAboveBottom = linePositionTopOnViewport < getClientHeightVisible();
+ var isVisible = lineBellowTop && lineAboveBottom;
+
+ return isVisible;
+ };
+
+ var getEditorScroll = function () {
+ var outer$ = helper.padOuter$;
+ var scrollTopFirefox = outer$('#outerdocbody').parent().scrollTop(); // works only on firefox
+ var scrollTop = outer$('#outerdocbody').scrollTop() || scrollTopFirefox;
+ return scrollTop;
+ };
+
+ // clientHeight includes padding, so we have to subtract it and consider only the visible viewport
+ var getClientHeightVisible = function () {
+ var outer$ = helper.padOuter$;
+ var $ace_outer = outer$('#outerdocbody').parent();
+ var ace_outerHeight = $ace_outer.get(0).clientHeight;
+ var ace_outerPaddingTop = getIntValueOfCSSProperty($ace_outer, 'padding-top');
+ var paddingAddedWhenPageViewIsEnable = getPaddingAddedWhenPageViewIsEnable();
+ var clientHeight = ace_outerHeight - ( ace_outerPaddingTop + paddingAddedWhenPageViewIsEnable);
+
+ return clientHeight;
+ };
+
+ // ep_page_view changes the dimensions of the editor. We have to guarantee
+ // the viewport height is calculated right
+ var getPaddingAddedWhenPageViewIsEnable = function () {
+ var chrome$ = helper.padChrome$;
+ var $outerIframe = chrome$('iframe');
+ var paddingAddedWhenPageViewIsEnable = parseInt($outerIframe.css('padding-top'));
+ return paddingAddedWhenPageViewIsEnable;
+ };
+
+ var getIntValueOfCSSProperty = function($element, property){
+ var valueString = $element.css(property);
+ return parseInt(valueString) || 0;
+ };
+
+ var forceUseMonospacedFont = function () {
+ helper.padChrome$.window.clientVars.padOptions.useMonospaceFont = true;
+ };
+
+ var setScrollPercentageWhenFocusLineIsOutOfViewport = function(value, editionAboveViewport) {
+ var scrollSettings = helper.padChrome$.window.clientVars.scrollWhenFocusLineIsOutOfViewport;
+ if (editionAboveViewport) {
+ scrollSettings.percentage.editionAboveViewport = value;
+ }else{
+ scrollSettings.percentage.editionBelowViewport = value;
+ }
+ };
+
+ var resetScrollPercentageWhenFocusLineIsOutOfViewport = function() {
+ var scrollSettings = helper.padChrome$.window.clientVars.scrollWhenFocusLineIsOutOfViewport;
+ scrollSettings.percentage.editionAboveViewport = 0;
+ scrollSettings.percentage.editionBelowViewport = 0;
+ };
+
+ var setPercentageToScrollWhenUserPressesArrowUp = function (value) {
+ var scrollSettings = helper.padChrome$.window.clientVars.scrollWhenFocusLineIsOutOfViewport;
+ scrollSettings.percentageToScrollWhenUserPressesArrowUp = value;
+ };
+
+ var scrollWhenPlaceCaretInTheLastLineOfViewport = function() {
+ var scrollSettings = helper.padChrome$.window.clientVars.scrollWhenFocusLineIsOutOfViewport;
+ scrollSettings.scrollWhenCaretIsInTheLastLineOfViewport = true;
+ };
+
+ var getLinePositionOnViewport = function(lineNumber) {
+ var $line = getLine(lineNumber);
+ var linePosition = $line.get(0).getBoundingClientRect();
+
+ // position relative to the current viewport
+ return linePosition.top - getEditorScroll();
+ };
+});
+