diff --git a/autoload/firenvim.vim b/autoload/firenvim.vim index 6c32fedf..794e4597 100644 --- a/autoload/firenvim.vim +++ b/autoload/firenvim.vim @@ -33,6 +33,18 @@ function! firenvim#focus_page() abort call rpcnotify(firenvim#get_chan(), 'firenvim_focus_page') endfunction +" Asks the browser extension to release focus from the frame and focus the +" next page element, matching the behavior of pressing `` in the page. +function! firenvim#focus_next() abort + call rpcnotify(firenvim#get_chan(), 'firenvim_focus_next') +endfunction + +" Asks the browser extension to release focus from the frame and focus the +" next page element, matching the behavior of pressing `` in the page. +function! firenvim#focus_prev() abort + call rpcnotify(firenvim#get_chan(), 'firenvim_focus_prev') +endfunction + " Asks the browser extension to hide the firenvim frame function! firenvim#hide_frame() abort call rpcnotify(firenvim#get_chan(), 'firenvim_hide_frame') diff --git a/src/FirenvimElement.ts b/src/FirenvimElement.ts index 91c199b6..e5b6b6e6 100644 --- a/src/FirenvimElement.ts +++ b/src/FirenvimElement.ts @@ -115,6 +115,11 @@ export class FirenvimElement { this.span = elem .ownerDocument .createElementNS("http://www.w3.org/1999/xhtml", "span"); + // Make non-focusable, as otherwise and in the page would + // focus the iframe at the end of the page instead of focusing the + // browser's UI. The only way to -focus the frame is to + // -focus the corresponding input element. + this.span.setAttribute("tabindex", "-1"); this.iframe = elem .ownerDocument .createElementNS("http://www.w3.org/1999/xhtml", "iframe") as HTMLIFrameElement; @@ -360,6 +365,10 @@ export class FirenvimElement { return p; } + getOriginalElement () { + return this.originalElement; + } + getSelector () { return computeSelector(this.getElement()); } diff --git a/src/Neovim.ts b/src/Neovim.ts index 7e487930..cb98f8b3 100644 --- a/src/Neovim.ts +++ b/src/Neovim.ts @@ -100,6 +100,12 @@ export async function neovim( case "firenvim_focus_input": lastLostFocus = performance.now(); return page.focusInput(); + case "firenvim_focus_next": + lastLostFocus = performance.now(); + return page.focusNext(); + case "firenvim_focus_prev": + lastLostFocus = performance.now(); + return page.focusPrev(); case "firenvim_hide_frame": lastLostFocus = performance.now(); return page.hideEditor(); diff --git a/src/compose.ts b/src/compose.ts index 4c6a655a..42e271f2 100644 --- a/src/compose.ts +++ b/src/compose.ts @@ -56,6 +56,8 @@ class ThunderbirdPageEventEmitter extends PageEventEmitter { async evalInPage(js: string) { return eval(js) } async focusInput() { return Promise.resolve(); } async focusPage() { return Promise.resolve(); } + async focusNext() { return Promise.resolve(); } + async focusPrev() { return Promise.resolve(); } async getEditorInfo() { return [document.location.href, "", [1, 1], undefined] as [string, string, [number, number], string] } async getElementContent() { const details = await browser.runtime.sendMessage({ funcName: ["getOwnComposeDetails"], args: [] }); diff --git a/src/page.ts b/src/page.ts index aa67e6c5..60ee635a 100644 --- a/src/page.ts +++ b/src/page.ts @@ -99,6 +99,90 @@ export function getActiveContentFunctions(global: IGlobalState) { }; } +function focusElementBeforeOrAfter(global: IGlobalState, frameId: number, i: 1 | -1) { + let firenvimElement; + if (frameId === undefined) { + firenvimElement = getFocusedElement(global.firenvimElems); + } else { + firenvimElement = global.firenvimElems.get(frameId); + } + const originalElement = firenvimElement.getOriginalElement(); + + const tabindex = (e: Element) => ((x => isNaN(x) ? 0 : x)(parseInt(e.getAttribute("tabindex")))); + const focusables = Array.from(document.querySelectorAll("input, select, textarea, button, object, [tabindex], [href]")) + .filter(e => e.getAttribute("tabindex") !== "-1") + .sort((e1, e2) => tabindex(e1) - tabindex(e2)); + + let index = focusables.indexOf(originalElement); + let elem: Element; + if (index === -1) { + // originalElement isn't in the list of focusables, so we have to + // figure out what the closest element is. We do this by iterating over + // all elements of the dom, accepting only originalElement and the + // elements that are focusable. Once we find originalElement, we select + // either the previous or next element depending on the value of i. + const treeWalker = document.createTreeWalker( + document.body, + NodeFilter.SHOW_ELEMENT, + { + acceptNode: n => ((n === originalElement || focusables.indexOf((n as Element)) !== -1) + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_REJECT) + }, + ); + const firstNode = treeWalker.currentNode as Element; + let cur = firstNode; + let prev; + while (cur && cur !== originalElement) { + prev = cur; + cur = treeWalker.nextNode() as Element; + } + if (i > 0) { + elem = treeWalker.nextNode() as Element; + } else { + elem = prev; + } + // Sanity check, can't be exercised + /* istanbul ignore next */ + if (!elem) { + elem = firstNode; + } + } else { + elem = focusables[(index + i + focusables.length) % focusables.length]; + } + + index = focusables.indexOf(elem); + // Sanity check, can't be exercised + /* istanbul ignore next */ + if (index === -1) { + throw "Oh my, something went wrong!"; + } + + // Now that we know we have an element that is in the focusable element + // list, iterate over the list to find one that is visible. + let startedAt; + let style = getComputedStyle(elem); + while (startedAt !== index && (style.visibility !== "visible" || style.display === "none")) { + if (startedAt === undefined) { + startedAt = index; + } + index = (index + i + focusables.length) % focusables.length; + elem = focusables[index]; + style = getComputedStyle(elem); + } + + (document.activeElement as any).blur(); + const sel = document.getSelection(); + sel.removeAllRanges(); + const range = document.createRange(); + if (elem.ownerDocument.contains(elem)) { + range.setStart(elem, 0); + } + range.collapse(true); + (elem as HTMLElement).focus(); + sel.addRange(range); +} + export function getNeovimFrameFunctions(global: IGlobalState) { return { evalInPage: (_: number, js: string) => executeInPage(js), @@ -117,6 +201,12 @@ export function getNeovimFrameFunctions(global: IGlobalState) { (document.activeElement as any).blur(); document.documentElement.focus(); }, + focusNext: (frameId: number) => { + focusElementBeforeOrAfter(global, frameId, 1); + }, + focusPrev: (frameId: number) => { + focusElementBeforeOrAfter(global, frameId, -1); + }, getEditorInfo: (frameId: number) => global .firenvimElems .get(frameId) diff --git a/tests/_common.ts b/tests/_common.ts index 47062105..5e15735e 100644 --- a/tests/_common.ts +++ b/tests/_common.ts @@ -440,6 +440,24 @@ export const testFocusInput = retryTest(withLocalPage("simple.html", async (test await server.pullCoverageData(frameSocket); })); +const focusNextPrevTest = async (testTitle: string, server: any, driver: webdriver.WebDriver) => { + const [input, span, frameSocket] = await createFirenvimFor(server, driver, By.id("content-input")); + await sendKeys(driver, ":call firenvim#focus_next()".split("") + .concat(webdriver.Key.ENTER)); + await driver.wait(async () => "" !== (await driver.switchTo().activeElement().getAttribute("id")), WAIT_DELAY, "focus_next did not change focused element"); + expect(await driver.switchTo().activeElement().getAttribute("id")).toBe("after"); + await driver.executeScript(`arguments[0].focus();`, input); + await driver.wait(async () => "after" !== (await driver.switchTo().activeElement().getAttribute("id")), WAIT_DELAY, "Page focus did not change"); + await sendKeys(driver, ":call firenvim#focus_prev()".split("") + .concat(webdriver.Key.ENTER)); + await driver.wait(async () => "" !== (await driver.switchTo().activeElement().getAttribute("id")), WAIT_DELAY, "focus_prev did not change focused element"); + expect(await driver.switchTo().activeElement().getAttribute("id")).toBe("before"); + await server.pullCoverageData(frameSocket); +} + +export const testFocusNextPrev1 = retryTest(withLocalPage("focusnext.html", focusNextPrevTest)); +export const testFocusNextPrev2 = retryTest(withLocalPage("focusnext2.html", focusNextPrevTest)); + export const testEvalJs = retryTest(withLocalPage("simple.html", async (testTitle: string, server: any, driver: webdriver.WebDriver) => { const backup = await readVimrc(); await writeVimrc(` diff --git a/tests/chrome.ts b/tests/chrome.ts index 6c51206b..141331eb 100644 --- a/tests/chrome.ts +++ b/tests/chrome.ts @@ -22,6 +22,8 @@ import { testEvalJs, testFilenameSettings, testFocusGainedLost, + testFocusNextPrev1, + testFocusNextPrev2, testForceNvimify, testGithubAutofill, testGStartedByFirenvim, @@ -166,6 +168,8 @@ describe("Chrome", () => { t("Force nvimify", testForceNvimify); t("Input focused after frame", testInputFocusedAfterLeave); t("FocusInput", testFocusInput); + t("FocusNextPrev1", testFocusNextPrev1); + t("FocusNextPrev2", testFocusNextPrev2); t("Dynamically created elements", testDynamicTextareas); t("Dynamically created nested elements", testNestedDynamicTextareas); t("Large buffers", testLargeBuffers); diff --git a/tests/firefox.ts b/tests/firefox.ts index 030cee51..994718e1 100644 --- a/tests/firefox.ts +++ b/tests/firefox.ts @@ -24,6 +24,8 @@ import { testEvalJs, testFilenameSettings, testFocusGainedLost, + testFocusNextPrev1, + testFocusNextPrev2, testForceNvimify, testGithubAutofill, testGStartedByFirenvim, @@ -139,6 +141,8 @@ describe("Firefox", () => { t("Force nvimify", testForceNvimify); t("Input focused after frame", testInputFocusedAfterLeave); t("FocusInput", testFocusInput); + t("FocusNextPrev1", testFocusNextPrev1); + t("FocusNextPrev2", testFocusNextPrev2); t("Dynamically created elements", testDynamicTextareas); t("Dynamically created nested elements", testNestedDynamicTextareas); t("Large buffers", testLargeBuffers); diff --git a/tests/pages/focusnext.html b/tests/pages/focusnext.html new file mode 100644 index 00000000..cc2eb8b9 --- /dev/null +++ b/tests/pages/focusnext.html @@ -0,0 +1,20 @@ + + + + + + focus_next()/focus_prev() + + + + before + + + nope + after + + diff --git a/tests/pages/focusnext2.html b/tests/pages/focusnext2.html new file mode 100644 index 00000000..cf3aaa26 --- /dev/null +++ b/tests/pages/focusnext2.html @@ -0,0 +1,20 @@ + + + + + + 2: focus_next()/focus_prev() + + + + before + + + nope + after + +