///
declare const PluginBase: IPluginBase;
let autoscrollIntervalId: number;
const SCROLL_SPEED_FACTORS = [240, 120, 90, 60, 36, 24, 12, 6];
const SCROLL_DURATION = 400;
const AUTOSCROLL_OPT = "autoscroll-index";
let scrollNodes: HTMLElement[] = [];
let scrollIndex: number = 0;
function stopAutoscroll(): void {
window.clearInterval(autoscrollIntervalId);
PluginBase.util.removeContext("Auto Scroll");
}
function setAutoscroll(indexDelta: number = 0) {
let prevPos: number | undefined;
const zoomFactor =
window.outerWidth / window.document.documentElement.clientWidth;
// need to add .1 (if it's less than a device pixel, no scrolling will happen)
const scrollFactor = Math.round((1 / zoomFactor) * 10) / 10 + 0.1;
const savedScrollSpeed = PluginBase.getPluginOption("Scroll", AUTOSCROLL_OPT);
let scrollSpeedIndex =
(typeof savedScrollSpeed === "number" ? savedScrollSpeed : 3) + indexDelta;
if (scrollSpeedIndex >= SCROLL_SPEED_FACTORS.length) {
scrollSpeedIndex = SCROLL_SPEED_FACTORS.length - 1;
} else if (scrollSpeedIndex < 0) {
scrollSpeedIndex = 0;
}
console.log("scroll speed", scrollSpeedIndex);
// save it as a preference
PluginBase.setPluginOption("Scroll", AUTOSCROLL_OPT, scrollSpeedIndex);
window.clearInterval(autoscrollIntervalId);
const scrollEl = getScrollEl();
if (scrollEl) {
autoscrollIntervalId = window.setInterval(() => {
// @ts-ignore
const scrollYPos = scrollEl.scrollY || scrollEl.scrollTop;
scrollEl.scrollBy(0, scrollFactor);
// if there was outside movement, or if we hit the bottom
if (
prevPos &&
(scrollYPos - prevPos <= 0 || scrollYPos - prevPos > scrollFactor * 1.5)
) {
console.log("stopping due to detected scroll activity");
stopAutoscroll();
}
prevPos = scrollYPos;
}, SCROLL_SPEED_FACTORS[scrollSpeedIndex]);
}
}
// The following inspired by Surfingkeys
// https://github.com/brookhong/Surfingkeys
function hasScroll(el: HTMLElement, direction: "y" | "x", barSize: number) {
const offset =
direction === "y" ? ["scrollTop", "height"] : ["scrollLeft", "width"];
let scrollPos = el[offset[0]];
if (scrollPos < barSize) {
// set scroll offset to barSize, and verify if we can get scroll offset as barSize
const originOffset = el[offset[0]];
el[offset[0]] = el.getBoundingClientRect()[offset[1]];
scrollPos = el[offset[0]];
// if (scrollPos !== originOffset) {
// Mode.suppressNextScrollEvent();
// }
el[offset[0]] = originOffset;
}
return scrollPos >= barSize;
}
function scrollableMousedownHandler(e: MouseEvent) {
const n = e.currentTarget!;
const target = e.target;
if (!n.contains(target)) return;
let index = scrollNodes.lastIndexOf(target);
if (index === -1) {
for (var i = scrollNodes.length - 1; i >= 0; i--) {
if (scrollNodes[i].contains(target)) {
index = i;
break;
}
}
if (index === -1) console.warn("cannot find scrollable", e.target);
}
scrollIndex = index;
}
/**
* Currently has a minimum threshold of 60 scrolling pixels
*/
function getScrollableEls(): HTMLElement[] {
console.time("getScrollableEls");
let nodes = listElements(
document.body,
NodeFilter.SHOW_ELEMENT,
function (n) {
// the offset height is how much is visible currently
return (
(hasScroll(n, "y", 16) && n.scrollHeight - n.offsetHeight > 60) ||
(hasScroll(n, "x", 16) && n.scrollWidth - n.scrollWidth > 60)
);
}
);
nodes.sort(function (a, b) {
if (b.contains(a)) return 1;
else if (a.contains(b)) return -1;
return b.scrollHeight * b.scrollWidth - a.scrollHeight * a.scrollWidth;
});
if (
document.scrollingElement!.scrollHeight > window.innerHeight ||
document.scrollingElement!.scrollWidth > window.innerWidth
) {
nodes.unshift(document.scrollingElement);
}
nodes.forEach(function (n) {
n.removeEventListener("mousedown", scrollableMousedownHandler);
n.addEventListener("mousedown", scrollableMousedownHandler);
});
console.timeEnd("getScrollableEls");
return nodes;
}
function listElements(root, whatToShow, filter): HTMLElement[] {
const elms: HTMLElement[] = [];
let currentNode: HTMLElement | null;
const nodeIterator = document.createNodeIterator(root, whatToShow, null);
while ((currentNode = nodeIterator.nextNode())) {
filter(currentNode) && elms.push(currentNode);
if (currentNode.shadowRoot) {
elms.push(...listElements(currentNode.shadowRoot, whatToShow, filter));
}
}
return elms;
}
// END surfingkeys inspiration
function getScrollEl(): HTMLElement | Window | undefined {
let el: HTMLElement | Window | undefined = window;
const helpBox = document.getElementById(
`${PluginBase.util.getNoCollisionUniqueAttr()}-helpBox`
);
if (helpBox && helpBox.scrollHeight > helpBox.clientHeight) {
el = helpBox;
} else if (
// document.location might be about:blank on google docs (e.g. if we're in a frame)
top!.location.href.startsWith("https://docs.google.com/document/")
) {
el = top!.document.querySelector(".kix-appview-editor")!;
} else if (
document.scrollingElement!.scrollHeight > window.innerHeight ||
document.scrollingElement!.scrollWidth > window.innerWidth
) {
el = document.scrollingElement!;
} else {
// find it the hard way
scrollNodes = getScrollableEls();
el = scrollNodes[scrollIndex];
}
return el;
}
async function scrollAmount(
{ top, left }: { top?: number; left?: number },
relative = true,
el = getScrollEl()
) {
if (el) {
const scrollObj = {
top,
left,
behavior: "smooth" as ScrollBehavior,
};
if (relative) {
el.scrollBy(scrollObj);
} else {
el.scrollTo(scrollObj);
}
}
// used to not need this because the scroll change would be enough,
// to cancel autoscrolling internally
stopAutoscroll();
return await PluginBase.util.sleep(SCROLL_DURATION);
}
// hd and hu are help down and help up respectively
type ScrollType = "u" | "d" | "l" | "r" | "t" | "b" | "hd" | "hu";
function scroll(direction: ScrollType, little: boolean = false): Promise {
// pdf needs keypresses
console.log("scrolling...", direction);
const needsKeyPressEvents = /\.pdf$/.test(document.location.pathname);
let factor: number;
// the key to press if we must scroll using the keyboard
let key: string;
switch (direction) {
case "u":
case "hu":
factor = -0.85;
key = "up";
break;
case "d":
case "hd":
factor = 0.85;
key = "down";
break;
case "l":
factor = -0.7;
key = "left";
break;
case "r":
factor = 0.7;
key = "right";
break;
case "t":
factor = 0;
key = "home";
break;
case "b":
factor = 10000;
key = "end";
break;
}
const littleFactor = little ? 0.5 : 1;
if (direction === "hd" || direction === "hu") {
const hud = PluginBase.util.getHUDEl()[0];
const helpContents = hud.querySelector("#help .cmds")!;
return scrollAmount(
{ top: helpContents.offsetHeight * factor! },
true,
helpContents
);
} else if (needsKeyPressEvents) {
let keys: string[][];
if (direction === "t" || direction === "b") {
keys = [[key!]];
} else {
keys = new Array(14 * littleFactor).fill([key!]);
}
chrome.runtime.sendMessage({
type: "pressKeys",
payload: {
keyWModifiers: keys,
nonChar: true,
},
});
return PluginBase.util.sleep(100);
} else {
let horizontal = direction === "l" || direction === "r";
if (horizontal)
return scrollAmount({ left: window.innerWidth * factor! * littleFactor });
else {
// special case for bottom
const relative = direction !== "t";
if (direction === "b" && document.body.scrollHeight != 0) {
// document.body.scrollHeight is too small on duckduckgo
return scrollAmount({ top: document.documentElement.scrollHeight });
}
return scrollAmount(
// window.innerHeight returning 1 for docs.google.com
{ top: top!.innerHeight! * factor! * littleFactor },
relative
);
}
}
}
function queryScrollPos(querySelector?: string) {
if (querySelector) {
const scrollEl = document.querySelector(querySelector)!;
return scrollEl.scrollTop;
} else {
return window.scrollY;
}
}
async function testScroll(
t: ExecutionContext,
say: (phrase?: string) => Promise,
client: WebdriverIO.Browser,
url: string,
querySelector?: string,
test: {
greater?: boolean;
lessThan?: boolean;
zero?: boolean;
} = { greater: true }
) {
await client.url(url);
// in case there's a redirect or something
t.is(await client.getUrl(), url);
// scroll down first
if (test.zero || test.lessThan)
// compound test
await say("bottom");
const scrollStart = await client.execute(queryScrollPos, querySelector);
await say();
const scrollEnd = await client.execute(queryScrollPos, querySelector);
if (test.greater)
t.true(
scrollEnd > scrollStart,
`scrollStart: ${scrollStart} scrollEnd: ${scrollEnd} for ${url}`
);
else if (test.lessThan)
t.true(
scrollEnd < scrollStart,
`scrollStart: ${scrollStart} scrollEnd: ${scrollEnd} for ${url}`
);
else if (test.zero)
t.is(
scrollEnd,
0,
`scrollStart: ${scrollStart} scrollEnd: ${scrollEnd} for ${url}`
);
}
export default {
...PluginBase,
...{
niceName: "Scroll",
description: "Commands for scrolling the page.",
icon: ``,
version: "4.10.0",
apiVersion: 2,
match: /.*/,
authors: "Miko",
homophones: {
autoscroll: "auto scroll",
horoscrope: "auto scroll",
app: "up",
upwards: "up",
upward: "up",
dumb: "down",
gout: "down",
downwards: "down",
town: "down",
don: "down",
downward: "down",
boreham: "bottom",
volume: "bottom",
barton: "bottom",
barn: "bottom",
born: "bottom",
littledown: "little down",
"put it down": "little down",
"will down": "little down",
middletown: "little down",
"little rock": "little up",
"lidl up": "little up",
"school little rock": "scroll little up",
"time of the page": "top of the page",
scrolltop: "scroll top",
"scrub top": "scroll top",
talk: "top",
chop: "top",
school: "scroll",
screw: "scroll",
small: "little",
flower: "slower",
lower: "slower",
pastor: "faster",
master: "faster",
"auto spa": "auto scroll",
scallop: "scroll up",
"scroll health": "scroll help",
"school health": "scroll help",
prohealth: "scroll help",
},
contexts: {
"Auto Scroll": {
commands: ["Speed Up", "Slow Down", "Stop Scrolling"],
},
},
destroy() {
stopAutoscroll();
},
commands: [
{
name: "Scroll Down",
match: ["[/scroll ]down"],
activeDocument: true,
// A delay would be alleviate mismatches between "little down" but isn't worth the slowdown
// delay: [300, 0],
pageFn: () => scroll("d"),
test: {
google: async (t, say, client) => {
// google search results (normal page)
await testScroll(
t,
say,
client,
"https://www.google.com/search?q=lipsurf"
);
},
gdocs: async (t, say, client) => {
await testScroll(
t,
say,
client,
"https://docs.google.com/document/d/1Tdfk2UvIXxwZOoluLh6o1kN1CrKHWbXcmUIsDKRHTEI/edit",
".kix-appview-editor"
);
},
gforms: async (t, say, client) => {
await testScroll(
t,
say,
client,
"https://docs.google.com/forms/d/e/1FAIpQLSe3_pOpyGtVkgk0D-Je0LWIzcNfR-ZusbGMKpnJ1g1ykdnF5A/viewform"
);
},
gmail: async (t, say, client) => {
await testScroll(
t,
say,
client,
`${t.context.localPageDomain}/gmail-long-message.html`,
"#\\:3"
);
},
whatsapp: async (t, say, client) => {
await testScroll(
t,
say,
client,
`${t.context.localPageDomain}/whatsapp.html`,
"._1_keJ"
);
},
quip: async (t, say, client) => {
await testScroll(
t,
say,
client,
`${t.context.localPageDomain}/quip.html`,
".parts-screen-body.scrollable"
);
},
iframe: async (t, say, client) => {
const getScrollPos = () => {
return document
.querySelector("iframe")!
.contentDocument?.querySelector("._1_keJ")!.scrollTop;
};
await client.url(`${t.context.localPageDomain}/scroll-iframe.html`);
await (await client.$("iframe")).click();
const scrollStart = (await client.execute(getScrollPos))!;
await say();
const scrollEnd = (await client.execute(getScrollPos))!;
t.true(
scrollEnd > scrollStart,
`scrollStart: ${scrollStart} scrollEnd: ${scrollEnd}`
);
},
"when address bar is selected": async (t, say, client) => {
await t.context.focusAddressBar();
await testScroll(
t,
say,
client,
"https://www.google.com/search?q=lipsurf"
);
},
"TODO: pdf": async (t, say, client) => {
// TODO: getting scroll position not working
const getScrollPos = () =>
client.execute(() => {
return document.scrollingElement!.scrollTop;
});
const scrollStart = await getScrollPos();
await client.url(`${t.context.localPageDomain}/sample.pdf`);
await say();
const scrollEnd = await getScrollPos();
t.fail();
},
},
},
{
name: "Scroll Up",
match: ["[/scroll ]up"],
activeDocument: true,
pageFn: () => scroll("u"),
},
{
name: "Auto Scroll",
match: ["[auto/automatic] scroll"],
description:
"Continuously scroll down the page slowly, at a reading pace.",
activeDocument: true,
pageFn: () => {
console.log("adding context");
PluginBase.util.enterContext([
"Auto Scroll",
...PluginBase.util.getContext(),
]);
setAutoscroll();
},
test: async (t, say, client) => {
const url = "https://www.google.com/search?q=lipsurf";
await client.url(url);
// in case there's a redirect or something
t.is(await client.getUrl(), url);
const scrollA = await client.execute(queryScrollPos);
await say();
const scrollB = await client.execute(queryScrollPos);
t.true(
scrollB > scrollA,
`scrollStart: ${scrollA} scrollEnd: ${scrollB} for ${url}`
);
await say("stop");
const scrollC = await client.execute(queryScrollPos);
await PluginBase.util.sleep(100);
const scrollD = await client.execute(queryScrollPos);
t.is(
scrollC,
scrollD,
`should have same scroll pos after stopping: ${scrollC} ${scrollD}`
);
},
},
{
name: "Slow Down",
match: ["slower", "slow down"],
description: "Slow down the auto scroll",
activeDocument: true,
normal: false,
pageFn: () => {
setAutoscroll(-1);
},
},
{
name: "Speed Up",
match: ["faster", "speed up"],
activeDocument: true,
normal: false,
description: "Speed up the auto scroll",
pageFn: () => {
setAutoscroll(1);
},
},
{
name: "Stop Scrolling",
match: ["stop", "pause"],
activeDocument: true,
normal: false,
description: "Stop the auto scrolling.",
pageFn: () => {
stopAutoscroll();
},
},
{
name: "Scroll Bottom",
match: [
"bottom[/ of page/of the page]",
"scroll [bottom/to bottom/to the bottom/to bottom of page/to the bottom of the page]",
],
activeDocument: true,
pageFn: () => {
return scroll("b");
},
test: async (t, say, client) => {
await testScroll(t, say, client, `http://motherfuckingwebsite.com/`);
},
},
{
name: "Scroll Top",
match: [
"top[/ of page/ of the page]",
"scroll [top/to top/to the top/to the top of the page]",
],
activeDocument: true,
pageFn: () => {
return scroll("t");
},
test: async (t, say, client) => {
await testScroll(
t,
say,
client,
`http://motherfuckingwebsite.com/`,
undefined,
{ zero: true }
);
},
},
{
name: "Scroll Help Down",
match: "scroll help down",
pageFn: () => scroll("hd", true),
},
{
name: "Scroll Help Up",
match: "scroll help up",
pageFn: () => scroll("hu", true),
},
{
name: "Scroll Down a Little",
match: ["little [scroll /]down"],
activeDocument: true,
pageFn: () => {
return scroll("d", true);
},
},
{
name: "Scroll Up a Little",
match: ["little [scroll /]up"],
activeDocument: true,
pageFn: () => {
return scroll("u", true);
},
},
{
name: "Scroll Left",
match: "scroll left",
activeDocument: true,
pageFn: () => {
return scroll("l");
},
},
{
name: "Scroll Right",
match: "scroll right",
activeDocument: true,
pageFn: () => {
return scroll("r");
},
},
],
},
};