diff --git a/settings.json.template b/settings.json.template index 0cb10d50e..699880bde 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 b7ec7cb28..060bca7b9 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 660b7afb3..cf7fea80a 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 424bacf5a..83f947bad 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); - } + // 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(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 - } + 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 000000000..bc3fd0076 --- /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
, + // 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
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 000000000..a53dc38c8 --- /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
is a line) + Browser Line = each vertical line. A
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 000000000..096b06b60 --- /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 = 'a
'; + 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(); + }; +}); +