Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 68 additions & 52 deletions src/static/js/caretPosition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,36 @@
// 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
//
// NOTE: every DOM lookup here has to go through the editor document that is
// passed in, *not* through the ambient `window` / `document`. This module is
// bundled into the pad's top-level window, while the caret lives in the
// ace_inner iframe nested inside ace_outer. Reading the top window's selection
// always yields an empty selection, which used to make getPosition() return
// null and crash the callers below (#8038).
import {Position, RepModel, RepNode} from "./types/RepModel";

export const getPosition = () => {
const range = getSelectionRange();
// @ts-ignore
if (!range || $(range.endContainer).closest('body')[0].id !== 'innerdocbody') return null;
export const getPosition = (doc: Document): Position | null => {
const range = getSelectionRange(doc);
if (!range || getBodyOf(range.endContainer)?.id !== 'innerdocbody') return null;
// 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. As we can't get the element height, we create a text node to get
// the dimensions on the position.
const clonedRange = createSelectionRange(range);
const shadowCaret = $(document.createTextNode('|'));
clonedRange.insertNode(shadowCaret[0]);
clonedRange.selectNode(shadowCaret[0]);
const shadowCaret = doc.createTextNode('|');
clonedRange.insertNode(shadowCaret);
clonedRange.selectNode(shadowCaret);
const line = getPositionOfElementOrSelection(clonedRange);
shadowCaret.remove();
return line;
};

// The caret node is usually a text node, so climb to an element first.
const getBodyOf = (node: Node) => {
const element = node.nodeType === Node.ELEMENT_NODE ? node as Element : node.parentElement;
return element ? element.closest('body') : null;
};

const createSelectionRange = (range: Range) => {
const clonedRange = range.cloneRange();

Expand All @@ -33,7 +45,7 @@ const createSelectionRange = (range: Range) => {
return clonedRange;
};

const getPositionOfRepLineAtOffset = (node: any, offset: number) => {
const getPositionOfRepLineAtOffset = (node: any, offset: number, doc: Document) => {
// it is not a text node, so we cannot make a selection
if (node.tagName === 'BR' || node.tagName === 'EMPTY') {
return getPositionOfElementOrSelection(node);
Expand All @@ -43,7 +55,7 @@ const getPositionOfRepLineAtOffset = (node: any, offset: number) => {
node = node.nextSibling as any;
}

const newRange = new Range();
const newRange = doc.createRange();
newRange.setStart(node, offset);
newRange.setEnd(node, offset);
const linePosition = getPositionOfElementOrSelection(newRange);
Expand All @@ -66,29 +78,31 @@ const getPositionOfElementOrSelection = (element: Range):Position => {
// 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
export const getPositionTopOfPreviousBrowserLine = (caretLinePosition: Position, rep: RepModel) => {
let previousLineTop = caretLinePosition.top - caretLinePosition.height; // [1]
const 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]
const lineBeforeCaretLine = rep.selStart[0] - 1;
const firstLineVisibleBeforeCaretLine = getPreviousVisibleLine(lineBeforeCaretLine, rep);
const linePosition =
getDimensionOfLastBrowserLineOfRepLine(firstLineVisibleBeforeCaretLine, rep);
previousLineTop = linePosition.top;
}
return previousLineTop;
};
export const getPositionTopOfPreviousBrowserLine =
(caretLinePosition: Position, rep: RepModel, doc: Document) => {
let previousLineTop = caretLinePosition.top - caretLinePosition.height; // [1]
const isCaretLineFirstBrowserLine =
caretLineIsFirstBrowserLine(caretLinePosition.top, rep, doc);

// 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]
const lineBeforeCaretLine = rep.selStart[0] - 1;
const firstLineVisibleBeforeCaretLine = getPreviousVisibleLine(lineBeforeCaretLine, rep);
const linePosition =
getDimensionOfLastBrowserLineOfRepLine(firstLineVisibleBeforeCaretLine, rep, doc);
previousLineTop = linePosition.top;
}
return previousLineTop;
};

const caretLineIsFirstBrowserLine = (caretLineTop: number, rep: RepModel) => {
const caretLineIsFirstBrowserLine = (caretLineTop: number, rep: RepModel, doc: Document) => {
const caretRepLine = rep.selStart[0];
const lineNode = rep.lines.atIndex(caretRepLine).lineNode;
const firstRootNode = getFirstRootChildNode(lineNode);

// to get the position of the node we get the position of the first char
const positionOfFirstRootNode = getPositionOfRepLineAtOffset(firstRootNode, 1);
const positionOfFirstRootNode = getPositionOfRepLineAtOffset(firstRootNode, 1, doc);
return positionOfFirstRootNode.top === caretLineTop;
};

Expand All @@ -101,13 +115,13 @@ const getFirstRootChildNode = (node: RepNode) => {
}
};

const getDimensionOfLastBrowserLineOfRepLine = (line: number, rep: RepModel) => {
const getDimensionOfLastBrowserLineOfRepLine = (line: number, rep: RepModel, doc: Document) => {
const lineNode = rep.lines.atIndex(line).lineNode;
const lastRootChildNode = getLastRootChildNode(lineNode);

// we get the position of the line in the last char of it
const lastRootChildNodePosition =
getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length);
getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length, doc);
return lastRootChildNodePosition;
};

Expand All @@ -127,31 +141,32 @@ const getLastRootChildNode = (node: RepNode) => {
// 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
export const getBottomOfNextBrowserLine = (caretLinePosition: Position, rep: RepModel) => {
let nextLineBottom = caretLinePosition.bottom + caretLinePosition.height; // [1]
const 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]
const nextLineAfterCaretLine = rep.selStart[0] + 1;
const firstNextLineVisibleAfterCaretLine = getNextVisibleLine(nextLineAfterCaretLine, rep);
const linePosition =
getDimensionOfFirstBrowserLineOfRepLine(firstNextLineVisibleAfterCaretLine, rep);
nextLineBottom = linePosition.bottom;
}
return nextLineBottom;
};
export const getBottomOfNextBrowserLine =
(caretLinePosition: Position, rep: RepModel, doc: Document) => {
let nextLineBottom = caretLinePosition.bottom + caretLinePosition.height; // [1]
const isCaretLineLastBrowserLine =
caretLineIsLastBrowserLineOfRepLine(caretLinePosition.top, rep, doc);

// 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]
const nextLineAfterCaretLine = rep.selStart[0] + 1;
const firstNextLineVisibleAfterCaretLine = getNextVisibleLine(nextLineAfterCaretLine, rep);
const linePosition =
getDimensionOfFirstBrowserLineOfRepLine(firstNextLineVisibleAfterCaretLine, rep, doc);
nextLineBottom = linePosition.bottom;
}
return nextLineBottom;
};

const caretLineIsLastBrowserLineOfRepLine = (caretLineTop: number, rep: RepModel) => {
const caretLineIsLastBrowserLineOfRepLine = (caretLineTop: number, rep: RepModel, doc: Document) => {
const caretRepLine = rep.selStart[0];
const lineNode = rep.lines.atIndex(caretRepLine).lineNode;
const lastRootChildNode = getLastRootChildNode(lineNode);

// we take a rep line and get the position of the last char of it
const lastRootChildNodePosition =
getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length);
getPositionOfRepLineAtOffset(lastRootChildNode.node, lastRootChildNode.length, doc);
return lastRootChildNodePosition.top === caretLineTop;
};

Expand Down Expand Up @@ -181,20 +196,21 @@ export const getNextVisibleLine = (line: number, rep: RepModel): number => {

const isLineVisible = (line: number, rep: RepModel) => rep.lines.atIndex(line).lineNode.offsetHeight > 0;

const getDimensionOfFirstBrowserLineOfRepLine = (line: number, rep: RepModel) => {
const getDimensionOfFirstBrowserLineOfRepLine = (line: number, rep: RepModel, doc: Document) => {
const lineNode = rep.lines.atIndex(line).lineNode;
const firstRootChildNode = getFirstRootChildNode(lineNode);

// we can get the position of the line, getting the position of the first char of the rep line
const firstRootChildNodePosition = getPositionOfRepLineAtOffset(firstRootChildNode, 1);
const firstRootChildNodePosition = getPositionOfRepLineAtOffset(firstRootChildNode, 1, doc);
return firstRootChildNodePosition;
};

const getSelectionRange = () => {
if (!window.getSelection) {
return;
const getSelectionRange = (doc: Document) => {
const win = doc.defaultView;
if (!win || !win.getSelection) {
return null;
}
const selection = window.getSelection();
const selection = win.getSelection();
if (selection && selection.type !== 'None' && selection.rangeCount > 0) {
return selection.getRangeAt(0);
} else {
Expand Down
72 changes: 53 additions & 19 deletions src/static/js/scroll.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {getBottomOfNextBrowserLine, getNextVisibleLine, getPosition, getPositionTopOfPreviousBrowserLine, getPreviousVisibleLine} from './caretPosition';
import {Position, RepModel, RepNode, WindowElementWithScrolling} from "./types/RepModel";
import {Position, RepModel, RepNode} from "./types/RepModel";


class Scroll {
Expand All @@ -18,6 +18,15 @@ class Scroll {
this.rootDocument = document;
}

// `outerWin` is the ace_outer *iframe element*, not a window — this module is
// bundled into the pad's top-level window and ace2_inner.ts hands us the
// element. Scrolling therefore has to go through its contentWindow: calling
// scrollTo()/scrollBy() on the element itself silently does nothing, because
// an iframe element has no scrollable box of its own.
_getOuterWin(): Window | null {
return this.outerWin.contentWindow;
};

scrollWhenCaretIsInTheLastLineOfViewportWhenNecessary(rep: RepModel, isScrollableEvent: boolean, innerHeight: number) {
// 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?
Expand Down Expand Up @@ -52,7 +61,19 @@ class Scroll {
}
}

// The editor document, i.e. the document of the ace_inner iframe nested
// inside ace_outer. This module runs in the pad's top-level window, so every
// caret measurement has to be made against this document rather than the
// ambient one. Resolved on demand because browsers (Firefox in particular)
// may replace an iframe's document after load.
_getInnerDoc(): Document | null {
const innerFrame = this.doc.getElementsByName('ace_inner')[0] as HTMLIFrameElement | undefined;
return innerFrame ? innerFrame.contentDocument : null;
};

_isCaretAtTheBottomOfViewport(rep: RepModel) {
const innerDoc = this._getInnerDoc();
if (!innerDoc) return false;
// 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
Expand All @@ -66,9 +87,11 @@ class Scroll {
this._isLinePartiallyVisibleOnViewport(firstLineVisibleAfterCaretLine, rep);
if (caretLineIsPartiallyVisibleOnViewport || lineAfterCaretLineIsPartiallyVisibleOnViewport) {
// check if the caret is in the bottom of the viewport
const caretLinePosition = getPosition()!;
const caretLinePosition = getPosition(innerDoc);
// no caret in the pad (e.g. focus is outside the editor), so nothing to scroll to
if (!caretLinePosition) return false;
const viewportBottom = this._getViewPortTopBottom().bottom;
const nextLineBottom = getBottomOfNextBrowserLine(caretLinePosition, rep);
const nextLineBottom = getBottomOfNextBrowserLine(caretLinePosition, rep, innerDoc);
return nextLineBottom > viewportBottom;
}
return false;
Expand Down Expand Up @@ -124,9 +147,9 @@ class Scroll {
};

_getScrollXY() {
const win = this.outerWin as WindowElementWithScrolling;
const win = this._getOuterWin();
const odoc = this.doc;
if (typeof (win.pageYOffset) === 'number') {
if (win && typeof (win.pageYOffset) === 'number') {
return {
x: win.pageXOffset,
y: win.pageYOffset,
Expand All @@ -139,29 +162,33 @@ class Scroll {
y: docel.scrollTop,
};
}
return {x: 0, y: 0};
};

getScrollX() {
return this._getScrollXY()!.x;
return this._getScrollXY().x;
};

getScrollY () {
return this._getScrollXY()!.y;
return this._getScrollXY().y;
};

setScrollX(x: number) {
this.outerWin.scrollTo(x, this.getScrollY());
this.setScrollXY(x, this.getScrollY());
};

setScrollY(y: number) {
this.outerWin.scrollTo(this.getScrollX(), y);
this.setScrollXY(this.getScrollX(), y);
};

setScrollXY(x: number, y: number) {
this.outerWin.scrollTo(x, y);
const win = this._getOuterWin();
if (win) win.scrollTo(x, y);
};

_isCaretAtTheTopOfViewport(rep: RepModel) {
const innerDoc = this._getInnerDoc();
if (!innerDoc) return false;
const caretLine = rep.selStart[0];
const linePrevCaretLine = caretLine - 1;
const firstLineVisibleBeforeCaretLine =
Expand All @@ -171,16 +198,18 @@ class Scroll {
const lineBeforeCaretLineIsPartiallyVisibleOnViewport =
this._isLinePartiallyVisibleOnViewport(firstLineVisibleBeforeCaretLine, rep);
if (caretLineIsPartiallyVisibleOnViewport || lineBeforeCaretLineIsPartiallyVisibleOnViewport) {
const caretLinePosition = getPosition(); // get the position of the browser line
const caretLinePosition = getPosition(innerDoc); // get the position of the browser line
// no caret in the pad (e.g. focus is outside the editor), so nothing to scroll to
if (!caretLinePosition) return false;
const viewportPosition = this._getViewPortTopBottom();
const viewportTop = viewportPosition.top;
const viewportBottom = viewportPosition.bottom;
const caretLineIsBelowViewportTop = caretLinePosition!.bottom >= viewportTop;
const caretLineIsAboveViewportBottom = caretLinePosition!.top < viewportBottom;
const caretLineIsBelowViewportTop = caretLinePosition.bottom >= viewportTop;
const caretLineIsAboveViewportBottom = caretLinePosition.top < viewportBottom;
const caretLineIsInsideOfViewport =
caretLineIsBelowViewportTop && caretLineIsAboveViewportBottom;
if (caretLineIsInsideOfViewport) {
const prevLineTop = getPositionTopOfPreviousBrowserLine(caretLinePosition!, rep);
const prevLineTop = getPositionTopOfPreviousBrowserLine(caretLinePosition, rep, innerDoc);
const previousLineIsAboveViewportTop = prevLineTop < viewportTop;
return previousLineIsAboveViewportTop;
}
Expand Down Expand Up @@ -229,7 +258,8 @@ class Scroll {
};

_scrollYPageWithoutAnimation(pixelsToScroll: number) {
this.outerWin.scrollBy(0, pixelsToScroll);
const win = this._getOuterWin();
if (win) win.scrollBy(0, pixelsToScroll);
};

_scrollYPageWithAnimation(pixelsToScroll: number, durationOfAnimationToShowFocusline: number) {
Expand Down Expand Up @@ -260,12 +290,14 @@ class Scroll {


scrollNodeVerticallyIntoView(rep: RepModel, innerHeight: number) {
const innerDoc = this._getInnerDoc();
if (!innerDoc) return;
const viewport = this._getViewPortTopBottom();

// when the selection changes outside of the viewport the browser automatically scrolls the line
// to inside of the viewport. Tested on IE, Firefox, Chrome in releases from 2015 until now
// So, when the line scrolled gets outside of the viewport we let the browser handle it.
const linePosition = getPosition();
const linePosition = getPosition(innerDoc);
if (linePosition) {
const distanceOfTopOfViewport = linePosition.top - viewport.top;
const distanceOfBottomOfViewport = viewport.bottom - linePosition.bottom - linePosition.height;
Expand All @@ -278,9 +310,11 @@ class Scroll {
} else if (caretIsBelowOfViewport) {
// setTimeout is required here as line might not be fully rendered onto the pad
setTimeout(() => {
const outer = window.parent;
// scroll to the very end of the pad outer
outer.scrollTo(0, outer[0].innerHeight);
const outer = this._getOuterWin();
// scroll to the very end of the pad outer. The inner frame is as tall
// as the whole document, so its scrollHeight is the end of the pad;
// scrollTo() clamps to the outer frame's maximum scroll offset.
if (outer) outer.scrollTo(0, innerDoc.documentElement.scrollHeight);
}, 150);
// if the above setTimeout and functionality is removed then hitting an enter
// key while on the last line wont be an optimal user experience
Expand Down
Loading
Loading