/// 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"); }, }, ], }, };