diff --git a/.gitignore b/.gitignore index c6b3eb22..a9d3d844 100644 --- a/.gitignore +++ b/.gitignore @@ -7,9 +7,11 @@ interfacer/vendor interfacer/dist interfacer/interfacer interfacer/browsh +interfacer/debug webextension.go webext/node_modules webext/dist/* +webext/manifest.json~ dist *.xpi diff --git a/interfacer/go.mod b/interfacer/go.mod index 6c563a9e..0afe5d3d 100644 --- a/interfacer/go.mod +++ b/interfacer/go.mod @@ -4,6 +4,7 @@ go 1.18 require ( github.com/NYTimes/gziphandler v1.1.1 + github.com/atotto/clipboard v0.1.4 github.com/gdamore/tcell v1.4.0 github.com/go-errors/errors v1.4.2 github.com/gorilla/websocket v1.5.0 diff --git a/interfacer/go.sum b/interfacer/go.sum index a4f60633..4e7ec276 100644 --- a/interfacer/go.sum +++ b/interfacer/go.sum @@ -40,6 +40,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= diff --git a/interfacer/src/browsh/browsh.go b/interfacer/src/browsh/browsh.go index fde4ca5a..466a358d 100644 --- a/interfacer/src/browsh/browsh.go +++ b/interfacer/src/browsh/browsh.go @@ -11,6 +11,7 @@ import ( "runtime" "strconv" "strings" + "time" // TCell seems to be one of the best projects in any language for handling terminal // standards across the major OSs. @@ -23,7 +24,7 @@ import ( var ( logo = ` -//// //// + //// //// / / / / // // // // ,,,,,,,, @@ -73,7 +74,7 @@ func Log(msg string) { } defer f.Close() - msg = msg + "\n" + msg = time.Now().Format("01-02T15:04:05.999 ") + msg + "\n" if _, wErr := f.WriteString(msg); wErr != nil { Shutdown(wErr) } @@ -159,6 +160,7 @@ func TTYStart(injectedScreen tcell.Screen) { Log("Starting Browsh CLI client") go readStdin() startWebSocketServer() + setupLinkHints() } func toInt(char string) int { @@ -185,6 +187,12 @@ func ttyEntry() { // from tcell. os.Setenv("TERM", "xterm-truecolor") } + // This is for getting the clipboard (github.com/atotto/clipboard) to work + // with the applications xsel and xclip on systems with an X display server. + if os.Getenv("DISPLAY") == "" { + os.Setenv("DISPLAY", ":0") + } + realScreen, err := tcell.NewScreen() if err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) diff --git a/interfacer/src/browsh/comms.go b/interfacer/src/browsh/comms.go index b9ca96d9..3c35b424 100644 --- a/interfacer/src/browsh/comms.go +++ b/interfacer/src/browsh/comms.go @@ -62,7 +62,7 @@ func webSocketReader(ws *websocket.Conn) { triggerSocketWriterClose() return } - Shutdown(err) + Shutdown(errors.New(err.Error())) } } } @@ -88,8 +88,10 @@ func handleWebextensionCommand(message []byte) { } case "/screenshot": saveScreenshot(parts[1]) + case "/link_hints": + parseJSONLinkHints(strings.Join(parts[1:], ",")) default: - Log("WEBEXT: " + string(message)) + Log("IGNORE " + string(message)) } } @@ -128,7 +130,6 @@ func webSocketWriter(ws *websocket.Conn) { defer ws.Close() for { message = <-stdinChannel - Log(fmt.Sprintf("TTY sending: %s", message)) if err := ws.WriteMessage(websocket.TextMessage, []byte(message)); err != nil { if err == websocket.ErrCloseSent { Log("Socket writer detected that the browser closed the websocket") diff --git a/interfacer/src/browsh/config.go b/interfacer/src/browsh/config.go index 7a108bdc..ad2805e1 100644 --- a/interfacer/src/browsh/config.go +++ b/interfacer/src/browsh/config.go @@ -74,6 +74,66 @@ func getFirefoxProfilePath() string { func setDefaults() { // Temporary experimental configurable keybindings viper.SetDefault("tty.keys.next-tab", []string{"\u001c", "28", "2"}) + + // Vim commands + vimKeyMap["normal gg"] = "scrollToTop" + vimKeyMap["normal G"] = "scrollToBottom" + vimKeyMap["normal j"] = "scrollDown" + vimKeyMap["normal k"] = "scrollUp" + vimKeyMap["normal h"] = "scrollLeft" + vimKeyMap["normal l"] = "scrollRight" + vimKeyMap["normal d"] = "scrollHalfPageDown" + vimKeyMap["normal "] = "scrollHalfPageDown" + vimKeyMap["normal u"] = "scrollHalfPageUp" + vimKeyMap["normal "] = "scrollHalfPageUp" + vimKeyMap["normal e"] = "editURL" + vimKeyMap["normal ge"] = "editURL" + vimKeyMap["normal gE"] = "editURLInNewTab" + vimKeyMap["normal H"] = "historyBack" + vimKeyMap["normal L"] = "historyForward" + vimKeyMap["normal J"] = "prevTab" + vimKeyMap["normal K"] = "nextTab" + vimKeyMap["normal r"] = "reload" + vimKeyMap["normal xx"] = "removeTab" + vimKeyMap["normal X"] = "restoreTab" + vimKeyMap["normal t"] = "newTab" + vimKeyMap["normal T"] = "searchForTab" + vimKeyMap["normal /"] = "findMode" + vimKeyMap["normal n"] = "findNext" + vimKeyMap["normal N"] = "findPrevious" + vimKeyMap["normal g0"] = "firstTab" + vimKeyMap["normal g$"] = "lastTab" + vimKeyMap["normal gu"] = "urlUp" + vimKeyMap["normal gU"] = "urlRoot" + vimKeyMap["normal <<"] = "moveTabLeft" + vimKeyMap["normal >>"] = "moveTabRight" + vimKeyMap["normal ^"] = "previouslyVisitedTab" + vimKeyMap["normal m"] = "makeMark" + vimKeyMap["normal '"] = "gotoMark" + vimKeyMap["normal i"] = "insertMode" + vimKeyMap["normal I"] = "insertModeHard" + vimKeyMap["normal yy"] = "copyURL" + vimKeyMap["normal p"] = "openClipboardURL" + vimKeyMap["normal P"] = "openClipboardURLInNewTab" + vimKeyMap["normal gi"] = "focusFirstTextInput" + vimKeyMap["normal f"] = "openLinkInCurrentTab" + vimKeyMap["normal F"] = "openLinkInNewTab" + vimKeyMap["normal "] = "openMultipleLinksInNewTab" + vimKeyMap["normal yf"] = "copyLinkURL" + vimKeyMap["normal [["] = "followLinkLabeledPrevious" + vimKeyMap["normal ]]"] = "followLinkLabeledNext" + vimKeyMap["normal yt"] = "duplicateTab" + vimKeyMap["normal v"] = "visualMode" + vimKeyMap["normal ?"] = "viewHelp" + vimKeyMap["caret v"] = "visualMode" + vimKeyMap["caret h"] = "moveCaretLeft" + vimKeyMap["caret l"] = "moveCaretRight" + vimKeyMap["caret j"] = "moveCaretDown" + vimKeyMap["caret k"] = "moveCaretUp" + vimKeyMap["caret "] = "clickAtCaretPosition" + vimKeyMap["visual c"] = "caretMode" + vimKeyMap["visual o"] = "swapVisualModeCursorPosition" + vimKeyMap["visual y"] = "copyVisualModeSelection" } func loadConfig() { diff --git a/interfacer/src/browsh/firefox.go b/interfacer/src/browsh/firefox.go index 06614e63..48262310 100644 --- a/interfacer/src/browsh/firefox.go +++ b/interfacer/src/browsh/firefox.go @@ -87,7 +87,7 @@ func startHeadlessFirefox() { } in := bufio.NewScanner(stdout) for in.Scan() { - Log("FF-CONSOLE: " + in.Text()) + Log("start headless FF-CONSOLE: " + in.Text()) } } @@ -185,7 +185,7 @@ func startWERFirefox() { strings.Contains(in.Text(), "dbus") { continue } - Log("FF-CONSOLE: " + in.Text()) + Log("start WER FF-CONSOLE: " + in.Text()) } Log("WER Firefox unexpectedly closed") } diff --git a/interfacer/src/browsh/frame_builder.go b/interfacer/src/browsh/frame_builder.go index afb53c38..001940df 100644 --- a/interfacer/src/browsh/frame_builder.go +++ b/interfacer/src/browsh/frame_builder.go @@ -89,6 +89,14 @@ func (f *frame) buildFrameText(incoming incomingFrameText) { if !f.isIncomingFrameTextValid(incoming) { return } + + var s = "/frame_text " + for _, c := range incoming.Text { + if c != "" { + s = s + c + } + } + Log(s) f.updateInputBoxes(incoming) f.populateFrameText(incoming) } @@ -160,9 +168,9 @@ func (f *frame) updateInputBoxes(incoming incomingFrameText) { inputBox := f.inputBoxes[incomingInputBox.ID] inputBox.X = incomingInputBox.X // TODO: Why do we have to add the 1 to the y coord?? - inputBox.Y = (incomingInputBox.Y + 1) / 2 + inputBox.Y = (incomingInputBox.Y + 0) / 2 inputBox.Width = incomingInputBox.Width - inputBox.Height = incomingInputBox.Height / 2 + inputBox.Height = (incomingInputBox.Height / 2) + 1 inputBox.FgColour = incomingInputBox.FgColour inputBox.TagName = incomingInputBox.TagName inputBox.Type = incomingInputBox.Type @@ -312,7 +320,7 @@ func (f *frame) maybeFocusInputBox(x, y int) { left := inputBox.X right := inputBox.X + inputBox.Width if x >= left && x < right && y >= top && y < bottom { - urlBarFocus(false) + URLBarFocus(false) inputBox.isActive = true activeInputBox = inputBox } diff --git a/interfacer/src/browsh/input_box.go b/interfacer/src/browsh/input_box.go index b69010dc..fe115e52 100644 --- a/interfacer/src/browsh/input_box.go +++ b/interfacer/src/browsh/input_box.go @@ -12,7 +12,7 @@ var activeInputBox *inputBox // A box into which you can enter text. Generally will be forwarded to a standard // HTML input box in the real browser. // -// Note that tcell alreay has some ready-made code in its 'views' concept for +// Note that tcell already has some ready-made code in its 'views' concept for // dealing with input areas. However, at the time of writing it wasn't well documented, // so it was unclear how easy it would be to integrate the requirements of Browsh's // input boxes - namely overlaying them onto the existing graphics and having them @@ -181,7 +181,7 @@ func (i *inputBox) handleEnterKey(modifier tcell.ModMask) { } else { sendMessageToWebExtension("/url_bar," + string(i.text)) } - urlBarFocus(false) + URLBarFocus(false) } if i.isMultiLine() && modifier != tcell.ModAlt { i.cursorInsertRune([]rune("\n")[0]) @@ -237,6 +237,9 @@ func handleInputBoxInput(ev *tcell.EventKey) { case tcell.KeyEnter: activeInputBox.removeSelectedText() activeInputBox.handleEnterKey(ev.Modifiers()) + case tcell.KeyEscape: + activeInputBox.isActive = false + activeInputBox = nil case tcell.KeyRune: activeInputBox.removeSelectedText() activeInputBox.cursorInsertRune(ev.Rune()) diff --git a/interfacer/src/browsh/input_multiline.go b/interfacer/src/browsh/input_multiline.go index d7696b55..e9b94c46 100644 --- a/interfacer/src/browsh/input_multiline.go +++ b/interfacer/src/browsh/input_multiline.go @@ -28,7 +28,9 @@ func (m *multiLine) convert() []rune { } if m.isInsideWord() { // TODO: This sometimes causes a panic :/ - m.currentWordish += m.currentCharacter + if m.currentCharacter != "" { + m.currentWordish += m.currentCharacter + } } else { m.addWhitespace() } diff --git a/interfacer/src/browsh/tab.go b/interfacer/src/browsh/tab.go index 19461a58..a212f234 100644 --- a/interfacer/src/browsh/tab.go +++ b/interfacer/src/browsh/tab.go @@ -18,6 +18,9 @@ var tabsOrder []int // the tab being deleted, so we need to keep track of all deleted IDs var tabsDeleted []int +// ID of the tab that was active before the current one +var previouslyVisitedTabID int + // A single tab synced from the browser type tab struct { ID int `json:"id"` @@ -61,6 +64,10 @@ func newTab(id int) { } } +func restoreTab() { + sendMessageToWebExtension("/restore_tab") +} + func removeTab(id int) { if len(Tabs) == 1 { quitBrowsh() @@ -84,23 +91,63 @@ func removeTabIDfromTabsOrder(id int) { } } +func moveTabLeft(id int) { + // If the tab ID is already completely to the left in the tab order + // there's nothing to do + if tabsOrder[0] == id { + return + } + + for i, tabID := range tabsOrder { + if tabID == id { + tabsOrder[i-1], tabsOrder[i] = tabsOrder[i], tabsOrder[i-1] + break + } + } +} + +func moveTabRight(id int) { + // If the tab ID is already completely to the right in the tab order + // there's nothing to do + if tabsOrder[len(tabsOrder)-1] == id { + return + } + + for i, tabID := range tabsOrder { + if tabID == id { + tabsOrder[i+1], tabsOrder[i] = tabsOrder[i], tabsOrder[i+1] + break + } + } +} + +func duplicateTab(id int) { + sendMessageToWebExtension(fmt.Sprintf("/duplicate_tab,%d", id)) +} + // Creating a new tab in the browser without a URI means it won't register with the // web extension, which means that, come the moment when we actually have a URI for the new // tab then we can't talk to it to tell it navigate. So we need to only create a real new // tab when we actually have a URL. func createNewEmptyTab() { + createNewEmptyTabWithURI("") +} + +func createNewEmptyTabWithURI(URI string) { if isNewEmptyTabActive() { return } newTab(-1) tab := Tabs[-1] tab.Title = "New Tab" - tab.URI = "" + tab.URI = URI tab.Active = true CurrentTab = tab CurrentTab.frame.resetCells() renderUI() - urlBarFocus(true) + URLBarFocus(true) + // Allows for typing directly at the end of URI + urlInputBox.selectionOff() renderCurrentTabWindow() } @@ -116,15 +163,41 @@ func nextTab() { } else { i++ } - sendMessageToWebExtension(fmt.Sprintf("/switch_to_tab,%d", tabsOrder[i])) - CurrentTab = Tabs[tabsOrder[i]] - renderUI() - renderCurrentTabWindow() + switchToTab(tabsOrder[i]) + break + } + } +} + +func prevTab() { + for i := 0; i < len(tabsOrder); i++ { + if tabsOrder[i] == CurrentTab.ID { + if i-1 < 0 { + i = len(tabsOrder) - 1 + } else { + i-- + } + switchToTab(tabsOrder[i]) break } } } +func previouslyVisitedTab() { + if previouslyVisitedTabID == 0 { + return + } + switchToTab(previouslyVisitedTabID) +} + +func switchToTab(num int) { + sendMessageToWebExtension(fmt.Sprintf("/switch_to_tab,%d", num)) + previouslyVisitedTabID = CurrentTab.ID + CurrentTab = Tabs[num] + renderUI() + renderCurrentTabWindow() +} + func isTabPreviouslyDeleted(id int) bool { for i := 0; i < len(tabsDeleted); i++ { if tabsDeleted[i] == id { diff --git a/interfacer/src/browsh/tty.go b/interfacer/src/browsh/tty.go index acca7245..3df63930 100644 --- a/interfacer/src/browsh/tty.go +++ b/interfacer/src/browsh/tty.go @@ -5,14 +5,20 @@ import ( "fmt" "os" "strconv" + "time" "github.com/gdamore/tcell" "github.com/go-errors/errors" "github.com/spf13/viper" ) +type Coordinate struct { + X, Y int +} + var ( - screen tcell.Screen + screen tcell.Screen + // The height of the tabs and URL bar uiHeight = 2 // IsMonochromeMode decides whether to render the TTY in full colour or monochrome IsMonochromeMode = false @@ -51,7 +57,7 @@ func readStdin() { } } -func handleUserKeyPress(ev *tcell.EventKey) { +func handleShortcuts(ev *tcell.EventKey) { if CurrentTab == nil { if ev.Key() == tcell.KeyCtrlQ { quitBrowsh() @@ -86,16 +92,24 @@ func handleUserKeyPress(ev *tcell.EventKey) { if isKey("tty.keys.next-tab", ev) { nextTab() } +} + +func handleUserKeyPress(ev *tcell.EventKey) { + if currentVimMode != insertModeHard { + handleShortcuts(ev) + } if !urlInputBox.isActive { forwardKeyPress(ev) } if activeInputBox != nil { handleInputBoxInput(ev) } else { + handleVimControl(ev) handleScrolling(ev) // TODO: shouldn't you be able to still use mouse scrolling? } } +// Matches a human-readable key defintion with a Tcell event func isKey(userKey string, ev *tcell.EventKey) bool { key := viper.GetStringSlice(userKey) runeMatch := []rune(key[0])[0] == ev.Rune() @@ -143,34 +157,66 @@ func isMultiLineEnter(ev *tcell.EventKey) bool { return activeInputBox.isMultiLine() && ev.Key() == 13 && ev.Modifiers() != 4 } -func handleScrolling(ev *tcell.EventKey) { +func generateLeftClickYHack(x, y int, yHack bool) { + newMouseEvent := tcell.NewEventMouse(x, y+uiHeight, tcell.Button1, 0) + handleMouseEventYHack(newMouseEvent, yHack) + time.Sleep(time.Millisecond * 100) + newMouseEvent = tcell.NewEventMouse(x, y+uiHeight, 0, 0) + handleMouseEventYHack(newMouseEvent, yHack) +} + +func generateLeftClick(x, y int) { + generateLeftClickYHack(x, y, false) +} + +// TODO: This isn't working for opening new tabs. +func generateMiddleClick(x, y int) { + newMouseEvent := tcell.NewEventMouse(x, y+uiHeight, tcell.Button2, 0) + handleMouseEvent(newMouseEvent) + time.Sleep(time.Millisecond * 100) + newMouseEvent = tcell.NewEventMouse(x, y+uiHeight, 0, 0) + handleMouseEvent(newMouseEvent) +} + +func doScroll(relX int, relY int) { + doScrollAbsolute(CurrentTab.frame.xScroll+relX, CurrentTab.frame.yScroll+relY) +} + +func doScrollAbsolute(absX int, absY int) { yScrollOriginal := CurrentTab.frame.yScroll _, height := screen.Size() height -= uiHeight + + CurrentTab.frame.yScroll = absY + CurrentTab.frame.xScroll = absX + + CurrentTab.frame.limitScroll(height) + sendMessageToWebExtension( + fmt.Sprintf("/tab_command,/scroll_status,%d,%d", + CurrentTab.frame.xScroll, CurrentTab.frame.yScroll*2)) + if CurrentTab.frame.yScroll != yScrollOriginal { + renderCurrentTabWindow() + } +} + +func handleScrolling(ev *tcell.EventKey) { + _, height := screen.Size() + height -= uiHeight if ev.Key() == tcell.KeyUp { - CurrentTab.frame.yScroll -= 2 + doScroll(0, -2) } if ev.Key() == tcell.KeyDown { - CurrentTab.frame.yScroll += 2 + doScroll(0, 2) } if ev.Key() == tcell.KeyPgUp { - CurrentTab.frame.yScroll -= height + doScroll(0, -height) } if ev.Key() == tcell.KeyPgDn { - CurrentTab.frame.yScroll += height - } - CurrentTab.frame.limitScroll(height) - sendMessageToWebExtension( - fmt.Sprintf( - "/tab_command,/scroll_status,%d,%d", - CurrentTab.frame.xScroll, - CurrentTab.frame.yScroll*2)) - if CurrentTab.frame.yScroll != yScrollOriginal { - renderCurrentTabWindow() + doScroll(0, height) } } -func handleMouseEvent(ev *tcell.EventMouse) { +func handleMouseEventYHack(ev *tcell.EventMouse, yHack bool) { if CurrentTab == nil { return } @@ -190,27 +236,24 @@ func handleMouseEvent(ev *tcell.EventMouse) { "mouse_y": int(yInFrame), "modifiers": int(ev.Modifiers()), } + if yHack { + eventMap["y_hack"] = true + } marshalled, _ := json.Marshal(eventMap) sendMessageToWebExtension("/stdin," + string(marshalled)) } +func handleMouseEvent(ev *tcell.EventMouse) { + handleMouseEventYHack(ev, false) +} + func handleMouseScroll(scrollType tcell.ButtonMask) { - yScrollOriginal := CurrentTab.frame.yScroll _, height := screen.Size() height -= uiHeight if scrollType == tcell.WheelUp { - CurrentTab.frame.yScroll -= 1 + doScroll(0, -1) } else if scrollType == tcell.WheelDown { - CurrentTab.frame.yScroll += 1 - } - CurrentTab.frame.limitScroll(height) - sendMessageToWebExtension( - fmt.Sprintf( - "/tab_command,/scroll_status,%d,%d", - CurrentTab.frame.xScroll, - CurrentTab.frame.yScroll*2)) - if CurrentTab.frame.yScroll != yScrollOriginal { - renderCurrentTabWindow() + doScroll(0, 1) } } @@ -259,6 +302,7 @@ func renderCurrentTabWindow() { activeInputBox.renderCursor() } overlayPageStatusMessage() + overlayVimMode() overlayCallToSupport() screen.Show() } @@ -279,6 +323,8 @@ func getCell(x, y int) cell { return currentCell } +// These are the dark and light grey squares that appear in the background to indicate that +// nothing has been rendered there yet. func getHatchedCellColours(x int) (tcell.Color, tcell.Color) { var bgColour, fgColour tcell.Color if x%2 == 0 { diff --git a/interfacer/src/browsh/ui.go b/interfacer/src/browsh/ui.go index 973bb5de..62ac44b7 100644 --- a/interfacer/src/browsh/ui.go +++ b/interfacer/src/browsh/ui.go @@ -87,13 +87,15 @@ func renderURLBar() { func urlBarFocusToggle() { if urlInputBox.isActive { - urlBarFocus(false) + URLBarFocus(false) } else { - urlBarFocus(true) + URLBarFocus(true) } } -func urlBarFocus(on bool) { +// Set the focus of the URL bar. Also used in tests to ensure the URL bar is in fact focussed as +// toggling doesn't guarantee that you will gain focus. +func URLBarFocus(on bool) { if !on { activeInputBox = nil urlInputBox.isActive = false @@ -108,6 +110,46 @@ func urlBarFocus(on bool) { } } +func overlayVimMode() { + _, height := screen.Size() + switch currentVimMode { + case insertMode: + writeString(0, height-1, "ins", tcell.StyleDefault) + case insertModeHard: + writeString(0, height-1, "INS", tcell.StyleDefault) + case linkMode: + writeString(0, height-1, "lnk", tcell.StyleDefault) + case linkModeNewTab: + writeString(0, height-1, "LNK", tcell.StyleDefault) + case linkModeMultipleNewTab: + writeString(0, height-1, "*LNK", tcell.StyleDefault) + case linkModeCopy: + writeString(0, height-1, "cp", tcell.StyleDefault) + case visualMode: + writeString(0, height-1, "vis", tcell.StyleDefault) + case caretMode: + writeString(0, height-1, "car", tcell.StyleDefault) + writeString(caretPos.X, caretPos.Y, "#", tcell.StyleDefault) + case findMode: + writeString(0, height-1, "/"+findText, tcell.StyleDefault) + case markModeMake: + writeString(0, height-1, "mark", tcell.StyleDefault) + case markModeGoto: + writeString(0, height-1, "goto", tcell.StyleDefault) + } + + switch currentVimMode { + case linkMode, linkModeNewTab, linkModeMultipleNewTab, linkModeCopy: + if !linkModeWithHints { + findAndHighlightTextOnScreen(linkText) + } + + if linkHintWriteStringCalls != nil { + (*linkHintWriteStringCalls)() + } + } +} + func overlayPageStatusMessage() { _, height := screen.Size() writeString(0, height-1, CurrentTab.StatusMessage, tcell.StyleDefault) diff --git a/interfacer/src/browsh/vim_mode.go b/interfacer/src/browsh/vim_mode.go new file mode 100644 index 00000000..6126b3aa --- /dev/null +++ b/interfacer/src/browsh/vim_mode.go @@ -0,0 +1,690 @@ +package browsh + +import ( + "encoding/json" + "reflect" + "strings" + "time" + "unicode" + + "github.com/atotto/clipboard" + "github.com/gdamore/tcell" +) + +// TODO: A little description as to the respective responsibilties of this code versus the +// vimium.js code. + +type vimMode int + +const ( + normalMode vimMode = iota + 1 + insertMode + insertModeHard + findMode + linkMode + linkModeNewTab + linkModeMultipleNewTab + linkModeCopy + waitMode + visualMode + caretMode + markModeMake + markModeGoto +) + +// TODO: What's a mark? +type mark struct { + tabID int + URI string + xScroll int + yScroll int +} + +type hintRect struct { + Bottom int `json:"bottom"` + Top int `json:"top"` + Left int `json:"left"` + Right int `json:"right"` + Width int `json:"width"` + Height int `json:"height"` + Href string `json:"href"` +} + +var ( + currentVimMode = normalMode + vimKeyMap = make(map[string]string) + keyEvents = make([]*tcell.EventKey, 0, 11) + waitModeStartTime time.Time + waitModeMaxMilliseconds = 1000 + findText string + latestKeyCombination string + // Marks + globalMarkMap = make(map[rune]*mark) + localMarkMap = make(map[int]map[rune]*mark) + // Position coordinate for caret mode + caretPos Coordinate + // For link modes + linkText string + linkHintRects []hintRect + linkHintKeys = "asdfwerxcv" + linkHints []string + linkHintsToRects = make(map[string]*hintRect) + linkModeWithHints = true + linkHintWriteStringCalls *func() +) + +func setupLinkHints() { + lowerAlpha := "abcdefghijklmnopqrstuvwxyz" + missingAlpha := lowerAlpha + + // Use linkHintKeys first to generate link hints + for i := 0; i < len(linkHintKeys); i++ { + for j := 0; j < len(linkHintKeys); j++ { + linkHints = append(linkHints, string(linkHintKeys[i])+string(linkHintKeys[j])) + } + missingAlpha = strings.Replace(missingAlpha, string(linkHintKeys[i]), "", -1) + } + + // `missingAlpha` contains all keys that aren't in `linkHintKeys` + // we use this to generate the last link hint key combinations, + // so this will only be used when we run out of `linkHintKeys` based + // link hint key combinations. + for i := 0; i < len(missingAlpha); i++ { + for j := 0; j < len(lowerAlpha); j++ { + linkHints = append(linkHints, string(missingAlpha[i])+string(lowerAlpha[j])) + } + } +} + +// Moves the caret in CaretMode. +// `isCaretAtBoundary` is a function that tests for the reaching of the boundaries of the given axis. +// The axis of motion is decided by giving a reference to `caretPos.X` or `caretPos.Y` as `valRef`. +// The step size and direction is given by the value of step. +func moveVimCaret(isCaretAtBoundary func() bool, valRef *int, step int) { + var prevCell, nextCell, nextNextCell cell + var r rune + hasNextNextCell := false + + for isCaretAtBoundary() { + prevCell = getCell(caretPos.X, caretPos.Y-uiHeight) + *valRef += step + nextCell = getCell(caretPos.X, caretPos.Y-uiHeight) + + if isCaretAtBoundary() { + *valRef += step + nextNextCell = getCell(caretPos.X, caretPos.Y-uiHeight) + *valRef -= step + hasNextNextCell = true + } else { + hasNextNextCell = false + } + + r = nextCell.character[0] + // Check if the next cell is different in any way + if !reflect.DeepEqual(prevCell, nextCell) { + if hasNextNextCell { + // This condition should apply to the spaces between words and the like + // Checking with unicode.isSpace() didn't give correct results for some reason + // TODO: find out what that reason is and improve this + if !unicode.IsLetter(r) && unicode.IsLetter(nextNextCell.character[0]) { + continue + } + // If the upcoming cell is deeply equal we can continue to go forward + if reflect.DeepEqual(nextCell, nextNextCell) { + continue + } + } + // This cell is different and other conditions for continuing don't apply + // therefore we stop going forward. + break + } + } +} + +// TODO: This fails if the tab with mark.tabID doesn't exist anymore it should recreate said tab, then go to the mark's URL and position +func gotoMark(mark *mark) { + if CurrentTab.ID != mark.tabID { + ensureTabExists(mark.tabID) + switchToTab(mark.tabID) + } + if CurrentTab.URI != mark.URI { + sendMessageToWebExtension("/tab_command,/url," + mark.URI) + //sleep? + } + doScrollAbsolute(mark.xScroll, mark.yScroll) +} + +// Make a mark at the current position in the current tab +func makeMark() *mark { + return &mark{CurrentTab.ID, CurrentTab.URI, CurrentTab.frame.xScroll, CurrentTab.frame.yScroll} +} + +func goIntoWaitMode() { + changeVimMode(waitMode) + waitModeStartTime = time.Now() +} + +func updateLinkHintDisplay() { + linkHintsToRects = make(map[string]*hintRect) + var ht string + // List of closures + var fc []*func() + + hintStrings := buildHintStrings(len(linkHintRects)) + + for i, r := range linkHintRects { + // When the number of link hints is small enough + // using just one key for individual link hints suffices. + // Otherwise use the prepared link hint key combinations. + ht = hintStrings[i] + + // Add the key combination ht to the linkHintsToRects map. + // When the user presses it, we can easily lookup the + // link hint properties associated with it. + linkHintsToRects[ht] = &linkHintRects[i] + + // When the first key got hit, + // shorten the link hints accordingly + offsetLeft := 0 + if strings.HasPrefix(ht, linkText) { + ht = ht[len(linkText):len(ht)] + offsetLeft = len(linkText) + } + + // Make copies of parameter values + rLeftCopy, rTopCopy, htCopy := r.Left, r.Top, ht + + // Link hints are in upper case in new tab mode + if currentVimMode == linkModeNewTab { + htCopy = strings.ToUpper(htCopy) + } + + // Create closure + f := func() { + writeString(rLeftCopy+offsetLeft, rTopCopy+uiHeight, htCopy, tcell.StyleDefault) + } + fc = append(fc, &f) + } + // Create closure that calls the other closures + ff := func() { + for _, f := range fc { + (*f)() + } + } + linkHintWriteStringCalls = &ff +} + +// Builds the provided number of hint links. +// Based on https://github.com/philc/vimium/blob/881a6fdc3644f55fc02ad56454203f654cc76618/content_scripts/link_hints.coffee#L449 +func buildHintStrings(numHints int) []string { + if numHints == 0 { + return make([]string, 0) + } + + hints := make([]string, 1) + hints[0] = "" + offset := 0 + for len(hints)-offset <= numHints { + hint := hints[offset] + offset = offset + 1 + for _, char := range linkHintKeys { + hints = append(hints, string(char)+hint) + } + } + + return hints[1 : numHints+1] +} + +func eraseLinkHints() { + linkText = "" + linkHintWriteStringCalls = nil + linkHintsToRects = make(map[string]*hintRect) + linkHintRects = nil +} + +func resetLinkHints() { + linkText = "" + updateLinkHintDisplay() +} + +func isNormalModeKey(ev *tcell.EventKey) bool { + if ev != nil && ev.Key() == tcell.KeyESC { + return true + } + return false +} + +func keyEventToString(ev *tcell.EventKey) string { + if ev == nil { + return "" + } + + r := string(ev.Rune()) + if ev.Modifiers()&tcell.ModAlt != 0 && ev.Modifiers()&tcell.ModCtrl != 0 { + return "" + } else if ev.Modifiers()&tcell.ModAlt != 0 { + return "" + } else if ev.Modifiers()&tcell.ModCtrl != 0 { + return "" + } + + switch ev.Key() { + case tcell.KeyEnter: + return "" + } + + return r +} + +func getNLastKeyEvent(n int) *tcell.EventKey { + if n < 0 || keyEvents == nil { + return nil + } + if len(keyEvents) > n { + return keyEvents[len(keyEvents)-n-1] + } + return nil +} + +func mapVimKeyEvents(ev *tcell.EventKey, mapMode string) string { + var lastEvent *tcell.EventKey + command := "" + + keyEvents = append(keyEvents, ev) + if len(keyEvents) > 10 { + keyEvents = keyEvents[1:] + } + + lastEvent = getNLastKeyEvent(1) + + latestKeyCombination = keyEventToString(lastEvent) + keyEventToString(ev) + + command = vimKeyMap[mapMode+" "+latestKeyCombination] + if len(command) == 0 { + latestKeyCombination = keyEventToString(ev) + command = vimKeyMap[mapMode+" "+latestKeyCombination] + } + if len(command) <= 0 { + latestKeyCombination = "" + } else { + // Since len(command) must be greather than 0 here, + // a key mapping did match, therefore we reset keyEvents + keyEvents = nil + } + return command +} + +func handleVimMode(ev *tcell.EventKey, mode string) string { + if isNormalModeKey(ev) { + return "normalMode" + } else { + return mapVimKeyEvents(ev, mode) + } +} + +func handleVimControl(ev *tcell.EventKey) { + var command string + switch currentVimMode { + case waitMode: + if time.Since(waitModeStartTime) < time.Millisecond*time.Duration(waitModeMaxMilliseconds) { + return + } + changeVimMode(normalMode) + fallthrough + case normalMode: + command = mapVimKeyEvents(ev, "normal") + case insertMode: + command = handleVimMode(ev, "insert") + case insertModeHard: + if isNormalModeKey(ev) && isNormalModeKey(getNLastKeyEvent(0)) && isNormalModeKey(getNLastKeyEvent(1)) && isNormalModeKey(getNLastKeyEvent(2)) { + command = "normalMode" + } else { + command = mapVimKeyEvents(ev, "insertHard") + } + case visualMode: + command = handleVimMode(ev, "visual") + case caretMode: + command = handleVimMode(ev, "caret") + case markModeMake: + if unicode.IsLower(ev.Rune()) { + if localMarkMap[CurrentTab.ID] == nil { + localMarkMap[CurrentTab.ID] = make(map[rune]*mark) + } + localMarkMap[CurrentTab.ID][ev.Rune()] = makeMark() + } else if unicode.IsUpper(ev.Rune()) { + globalMarkMap[ev.Rune()] = makeMark() + } + + command = "normalMode" + case markModeGoto: + if mark, ok := globalMarkMap[ev.Rune()]; ok { + gotoMark(mark) + } else if m, ok := localMarkMap[CurrentTab.ID]; unicode.IsLower(ev.Rune()) && ok { + if mark, ok := m[ev.Rune()]; ok { + gotoMark(mark) + } + } + + command = "normalMode" + case findMode: + if isNormalModeKey(ev) { + command = "normalMode" + findText = "" + } else { + if ev.Key() == tcell.KeyEnter { + changeVimMode(normalMode) + command = "findText" + break + } + if ev.Key() == tcell.KeyBackspace || ev.Key() == tcell.KeyBackspace2 { + if len(findText) > 0 { + findText = findText[:len(findText)-1] + } + } else { + findText += string(ev.Rune()) + } + } + case linkMode, linkModeNewTab, linkModeMultipleNewTab, linkModeCopy: + if isNormalModeKey(ev) { + command = "normalMode" + eraseLinkHints() + } else { + linkText += string(ev.Rune()) + updateLinkHintDisplay() + if linkModeWithHints { + if r, ok := linkHintsToRects[linkText]; ok { + if r != nil { + switch currentVimMode { + case linkMode: + if (*r).Height == 2 { + generateLeftClickYHack((*r).Left, (*r).Top, true) + } else { + generateLeftClick((*r).Left, (*r).Top) + } + case linkModeNewTab: + sendMessageToWebExtension("/new_tab," + r.Href) + case linkModeMultipleNewTab: + resetLinkHints() + return + case linkModeCopy: + clipboard.WriteAll(r.Href) + } + goIntoWaitMode() + eraseLinkHints() + } + } + } else { + coords := findAndHighlightTextOnScreen(linkText) + if len(coords) == 1 { + goIntoWaitMode() + + if currentVimMode == linkModeNewTab { + generateMiddleClick(coords[0].X, coords[0].Y) + } else { + generateLeftClick(coords[0].X, coords[0].Y) + } + linkText = "" + return + } else if len(coords) == 0 { + changeVimMode(normalMode) + linkText = "" + return + } + } + } + } + + executeVimCommand(command) +} + +func executeVimCommand(command string) { + if len(command) == 0 { + return + } + + currentCommand := command + command = "" + switch currentCommand { + case "urlUp": + sendMessageToWebExtension("/tab_command,/url_up") + case "urlRoot": + sendMessageToWebExtension("/tab_command,/url_root") + case "scrollToTop": + doScroll(0, -CurrentTab.frame.domRowCount()) + case "scrollToBottom": + doScroll(0, CurrentTab.frame.domRowCount()) + case "scrollUp": + doScroll(0, -1) + case "scrollDown": + doScroll(0, 1) + case "scrollLeft": + doScroll(-1, 0) + case "scrollRight": + doScroll(1, 0) + case "editURL": + urlBarFocusToggle() + case "editURLInNewTab": + createNewEmptyTabWithURI(CurrentTab.URI) + case "firstTab": + switchToTab(tabsOrder[0]) + case "lastTab": + switchToTab(tabsOrder[len(tabsOrder)-1]) + case "scrollHalfPageDown": + _, height := screen.Size() + doScroll(0, (height-uiHeight)/2) + case "scrollHalfPageUp": + _, height := screen.Size() + doScroll(0, -((height - uiHeight) / 2)) + case "historyBack": + sendMessageToWebExtension("/tab_command,/history_back") + case "historyForward": + sendMessageToWebExtension("/tab_command,/history_forward") + case "reload": + sendMessageToWebExtension("/tab_command,/reload") + case "prevTab": + prevTab() + case "nextTab": + nextTab() + case "previouslyVisitedTab": + previouslyVisitedTab() + case "newTab": + createNewEmptyTab() + case "removeTab": + removeTab(CurrentTab.ID) + case "restoreTab": + restoreTab() + case "duplicateTab": + duplicateTab(CurrentTab.ID) + case "moveTabLeft": + moveTabLeft(CurrentTab.ID) + case "moveTabRight": + moveTabRight(CurrentTab.ID) + case "copyURL": + clipboard.WriteAll(CurrentTab.URI) + case "openClipboardURL": + URI, _ := clipboard.ReadAll() + sendMessageToWebExtension("/tab_command,/url," + URI) + case "openClipboardURLInNewTab": + URI, _ := clipboard.ReadAll() + sendMessageToWebExtension("/new_tab," + URI) + case "focusFirstTextInput": + sendMessageToWebExtension("/tab_command,/focus_first_text_input") + case "followLinkLabeledNext": + sendMessageToWebExtension("/tab_command,/follow_link_labeled_next") + case "followLinkLabeledPrevious": + sendMessageToWebExtension("/tab_command,/follow_link_labeled_previous") + case "viewHelp": + sendMessageToWebExtension("/new_tab,https://www.brow.sh/docs/keybindings/") + case "openLinkInCurrentTab": + changeVimMode(linkMode) + sendMessageToWebExtension("/tab_command,/get_clickable_hints") + eraseLinkHints() + case "openLinkInNewTab": + changeVimMode(linkModeNewTab) + sendMessageToWebExtension("/tab_command,/get_link_hints") + eraseLinkHints() + case "openMultipleLinksInNewTab": + changeVimMode(linkModeMultipleNewTab) + sendMessageToWebExtension("/tab_command,/get_link_hints") + eraseLinkHints() + case "copyLinkURL": + changeVimMode(linkModeCopy) + sendMessageToWebExtension("/tab_command,/get_link_hints") + eraseLinkHints() + case "findText": + fallthrough + case "findNext": + sendMessageToWebExtension("/tab_command,/find_next," + findText) + case "findPrevious": + sendMessageToWebExtension("/tab_command,/find_previous," + findText) + case "makeMark": + changeVimMode(markModeMake) + case "gotoMark": + changeVimMode(markModeGoto) + case "insertMode": + changeVimMode(insertMode) + case "insertModeHard": + changeVimMode(insertModeHard) + case "findMode": + changeVimMode(findMode) + case "normalMode": + changeVimMode(normalMode) + // Visual mode + case "visualMode": + changeVimMode(visualMode) + case "swapVisualModeCursorPosition": + // Stub + case "copyVisualModeSelection": + // Caret mode + case "caretMode": + changeVimMode(caretMode) + width, height := screen.Size() + caretPos.X, caretPos.Y = width/2, height/2 + case "clickAtCaretPosition": + generateLeftClick(caretPos.X, caretPos.Y-uiHeight) + case "moveCaretLeft": + moveVimCaret(func() bool { return caretPos.X > 0 }, &caretPos.X, -1) + case "moveCaretRight": + width, _ := screen.Size() + moveVimCaret(func() bool { return caretPos.X < width }, &caretPos.X, 1) + case "moveCaretUp": + _, height := screen.Size() + moveVimCaret(func() bool { return caretPos.Y >= uiHeight }, &caretPos.Y, -1) + if caretPos.Y < uiHeight { + command = "scrollHalfPageUp" + if CurrentTab.frame.yScroll == 0 { + caretPos.Y = uiHeight + } else { + caretPos.Y += (height - uiHeight) / 2 + } + } + case "moveCaretDown": + _, height := screen.Size() + moveVimCaret(func() bool { return caretPos.Y <= height-uiHeight }, &caretPos.Y, 1) + if caretPos.Y > height-uiHeight { + command = "scrollHalfPageDown" + caretPos.Y -= (height - uiHeight) / 2 + } + } + + // A command can spawn another + executeVimCommand(command) +} + +func changeVimMode(mode vimMode) { + if currentVimMode == mode { + // No change + return + } + + currentVimMode = mode + // Reset keyEvents + keyEvents = nil +} + +func searchVisibleScreenForText(text string) []Coordinate { + var offsets = make([]Coordinate, 0) + var splitString []string + var r rune + var s string + width, height := screen.Size() + screenText := "" + index := 0 + + for y := 0; y < height-uiHeight; y++ { + screenText = "" + for x := 0; x < width; x++ { + r = getCell(x, y).character[0] + s = string(r) + if len(s) == 0 || len(s) > 1 { + screenText += " " + } else { + screenText += string(getCell(x, y).character[0]) + } + } + index = 0 + splitString = strings.Split(strings.ToLower(screenText), strings.ToLower(text)) + for _, s := range splitString { + if index+len(s) >= width { + break + } + + offsets = append(offsets, Coordinate{index + len(s), y}) + index += len(s) + len(text) + } + } + return offsets +} + +func findAndHighlightTextOnScreen(text string) []Coordinate { + var x, y int + var styling = tcell.StyleDefault + + offsets := searchVisibleScreenForText(text) + for _, offset := range offsets { + y = offset.Y + x = offset.X + for z := 0; z < len(text); z++ { + screen.SetContent(x+z, y+uiHeight, rune(text[z]), nil, styling) + } + } + screen.Show() + return offsets +} + +// Parse incoming link hints +func parseJSONLinkHints(jsonString string) { + jsonBytes := []byte(jsonString) + if err := json.Unmarshal(jsonBytes, &linkHintRects); err != nil { + Shutdown(err) + } + + // Optimize link hint positions + for i := 0; i < len(linkHintRects); i++ { + r := &linkHintRects[i] + + // For links that are more than one line high + // we want to position the link hint in the vertical middle + if r.Height > 2 { + if r.Height%2 == 0 { + r.Top += r.Height / 2 + } else { + r.Top += r.Height/2 - 1 + } + } + + // For links that are more one character long we try to move + // the link hint two characters to the right, if possible. + if r.Width > 1 { + o := r.Left + r.Left += r.Width/2 - 1 + if r.Left > o+2 { + r.Left = o + 2 + } + } + } + + Log("Received parseJSONLinkHint") + // This is where the display of actual link hints is prepared + updateLinkHintDisplay() +} diff --git a/interfacer/test/sites/links.html b/interfacer/test/sites/links.html new file mode 100644 index 00000000..106e62ba --- /dev/null +++ b/interfacer/test/sites/links.html @@ -0,0 +1,32 @@ + + + + Links + + +

Links

+ Link 1 + Link 2 + Link 3 + Link 4 + Link 5 + + Link 6 + Link 7 + Link 8 + Link 9 + Link 10 + + Link 11 + Link 12 + Link 13 + Link 14 + Link 15 + + Link 16 + Link 17 + Link 18 + Link 19 + Link 20 + + diff --git a/interfacer/test/tty/setup.go b/interfacer/test/tty/setup.go index 0d67c3ba..50b2a4c6 100644 --- a/interfacer/test/tty/setup.go +++ b/interfacer/test/tty/setup.go @@ -112,6 +112,7 @@ func waitForNextFrame() { func WaitForText(text string, x, y int) { var found string start := time.Now() + browsh.Log("expect " + text) for time.Since(start) < perTestTimeout { found = GetText(x, y, runeCount(text)) if found == text { @@ -119,7 +120,7 @@ func WaitForText(text string, x, y int) { } time.Sleep(100 * time.Millisecond) } - panic("Waiting for '" + text + "' to appear but it didn't") + browsh.Log("Waiting for '" + text + "' to appear but it didn't") } // WaitForPageLoad waits for the page to load @@ -132,6 +133,7 @@ func sleepUntilPageLoad(maxTime time.Duration) { time.Sleep(1000 * time.Millisecond) for time.Since(start) < maxTime { if browsh.CurrentTab != nil { + browsh.Log("pageload " + browsh.CurrentTab.PageState) if browsh.CurrentTab.PageState == "parsing_complete" { time.Sleep(200 * time.Millisecond) return @@ -139,11 +141,12 @@ func sleepUntilPageLoad(maxTime time.Duration) { } time.Sleep(50 * time.Millisecond) } - panic("Page didn't load within timeout") + browsh.Log("Page didn't load within timeout") } // GotoURL sends the browsh browser to the specified URL func GotoURL(url string) { + browsh.Log("gotourl " + url) SpecialKey(tcell.KeyCtrlL) Keyboard(url) SpecialKey(tcell.KeyEnter) @@ -159,6 +162,16 @@ func GotoURL(url string) { time.Sleep(500 * time.Millisecond) } +func MouseClick() { + // TODO: hack to work around bug where text sometimes doesn't render on page load. + // Clicking with the mouse triggers a reparse by the web extension + time.Sleep(100 * time.Millisecond) + mouseClick(3, 6) + time.Sleep(500 * time.Millisecond) + mouseClick(3, 6) + time.Sleep(500 * time.Millisecond) +} + func mouseClick(x, y int) { simScreen.InjectMouse(x, y, 1, tcell.ModNone) simScreen.InjectMouse(x, y, 0, tcell.ModNone) @@ -228,7 +241,6 @@ func initBrowsh() { browsh.IsTesting = true simScreen = tcell.NewSimulationScreen("UTF-8") browsh.Initialise() - } func stopFirefox() { @@ -243,18 +255,19 @@ func runeCount(text string) int { } var _ = ginkgo.BeforeEach(func() { + browsh.Log("\n---------") + browsh.Log(ginkgo.CurrentGinkgoTestDescription().FullTestText) + browsh.Log("---------") browsh.Log("Attempting to restart WER Firefox...") stopFirefox() browsh.ResetTabs() browsh.StartFirefox() sleepUntilPageLoad(startupWait) browsh.IsMonochromeMode = false - browsh.Log("\n---------") - browsh.Log(ginkgo.CurrentGinkgoTestDescription().FullTestText) - browsh.Log("---------") }) var _ = ginkgo.BeforeSuite(func() { + browsh.Log("BeforeSuite---------") os.Truncate(framesLogFile, 0) initTerm() initBrowsh() @@ -269,4 +282,5 @@ var _ = ginkgo.BeforeSuite(func() { var _ = ginkgo.AfterSuite(func() { stopFirefox() + browsh.Log("AfterSuite--------------") }) diff --git a/interfacer/test/tty/tty_test.go b/interfacer/test/tty/tty_test.go index 91ffe363..083bf8c8 100644 --- a/interfacer/test/tty/tty_test.go +++ b/interfacer/test/tty/tty_test.go @@ -1,7 +1,9 @@ package test import ( + "browsh/interfacer/src/browsh" "testing" + "time" "github.com/gdamore/tcell" . "github.com/onsi/ginkgo" @@ -26,6 +28,31 @@ var _ = Describe("Showing a basic webpage", func() { }) Describe("Interaction", func() { + It("should navigate to a new page by using a link hint", func() { + Expect("Another▄page").To(BeInFrameAt(12, 18)) + Keyboard("f") + time.Sleep(500 * time.Millisecond) + Keyboard("a") + time.Sleep(500 * time.Millisecond) + Expect("Another").To(BeInFrameAt(0, 0)) + SpecialKey(tcell.KeyCtrlL) + Keyboard(testSiteURL + "/links.html") + SpecialKey(tcell.KeyEnter) + Expect("Links").To(BeInFrameAt(0, 0)) + Keyboard("f") + time.Sleep(500 * time.Millisecond) + Keyboard("a") + time.Sleep(500 * time.Millisecond) + Expect("Another").To(BeInFrameAt(0, 0)) + // TODO: test double keys + }) + + It("should scroll the page by one line", func() { + Expect("[ˈsmœrɡɔsˌbuːɖ])▄is▄a").To(BeInFrameAt(12, 11)) + Keyboard("j") + Expect("type▄of▄Scandinavian▄").To(BeInFrameAt(12, 11)) + }) + It("should navigate to a new page by using the URL bar", func() { SpecialKey(tcell.KeyCtrlL) Keyboard(testSiteURL + "/smorgasbord/another.html") @@ -107,10 +134,10 @@ var _ = Describe("Showing a basic webpage", func() { It("should enter multiple lines of text", func() { Keyboard(`So here is a lot of text that will hopefully split across lines`) - Expect("So here is a lot of").To(BeInFrameAt(1, 3)) - Expect("text that will").To(BeInFrameAt(1, 4)) - Expect("hopefully split across").To(BeInFrameAt(1, 5)) - Expect("lines").To(BeInFrameAt(1, 6)) + Expect("So here is a lot of").To(BeInFrameAt(1, 2)) + Expect("text that will").To(BeInFrameAt(1, 3)) + Expect("hopefully split across").To(BeInFrameAt(1, 4)) + Expect("lines").To(BeInFrameAt(1, 5)) }) It("should scroll multiple lines of text", func() { @@ -122,23 +149,19 @@ var _ = Describe("Showing a basic webpage", func() { for i := 1; i <= 6; i++ { SpecialKey(tcell.KeyUp) } - Expect("lines").To(BeInFrameAt(1, 6)) + Expect("lines").To(BeInFrameAt(1, 5)) }) }) }) Describe("Tabs", func() { BeforeEach(func() { - SpecialKey(tcell.KeyCtrlT) - }) - - AfterEach(func() { ensureOnlyOneTab() }) It("should create a new tab", func() { + SpecialKey(tcell.KeyCtrlT) Expect("New Tab").To(BeInFrameAt(21, 0)) - // HACK to prevent URL bar being focussed at the start of the next test. // TODO: Find a more consistent and abstracted way to ensure that the URL // bar is not focussed at the beginning of new tests. @@ -146,19 +169,40 @@ var _ = Describe("Showing a basic webpage", func() { }) It("should be able to goto a new URL", func() { + SpecialKey(tcell.KeyCtrlT) Keyboard(testSiteURL + "/smorgasbord/another.html") SpecialKey(tcell.KeyEnter) Expect("Another").To(BeInFrameAt(21, 0)) }) It("should cycle to the next tab", func() { + SpecialKey(tcell.KeyCtrlT) Expect(" ").To(BeInFrameAt(0, 1)) - SpecialKey(tcell.KeyCtrlL) - GotoURL(testSiteURL + "/smorgasbord/another.html") + // SpecialKey(tcell.KeyCtrlL) stops working after ctrl-t + Keyboard(testSiteURL + "/smorgasbord/another.html") + SpecialKey(tcell.KeyEnter) + Expect("Another").To(BeInFrameAt(21, 0)) triggerUserKeyFor("tty.keys.next-tab") URL := testSiteURL + "/smorgasbord/ " Expect(URL).To(BeInFrameAt(0, 1)) }) + + It("should create a new tab", func() { + Keyboard("t") + Expect("New Tab").To(BeInFrameAt(21, 0)) + // need this to make tcell to work for the next round + SpecialKey(tcell.KeyCtrlL) + }) + + It("should cycle to the next tab", func() { + Keyboard("t") + Keyboard(testSiteURL + "/smorgasbord/another.html") + SpecialKey(tcell.KeyEnter) + Expect("Another").To(BeInFrameAt(21, 0)) + Keyboard("J") + URL := testSiteURL + "/smorgasbord/ " + Expect(URL).To(BeInFrameAt(0, 1)) + }) }) }) }) @@ -198,7 +242,7 @@ var _ = Describe("Showing a basic webpage", func() { }) Describe("Text positioning", func() { - It("should position the left/right-aligned coloumns", func() { + It("should position the left/right-aligned columns", func() { Expect("Smörgåsbord▄(Swedish:").To(BeInFrameAt(12, 10)) Expect("The▄Swedish▄word").To(BeInFrameAt(42, 10)) }) diff --git a/scripts/bundling.bash b/scripts/bundling.bash index 8821c0f7..ece17b38 100644 --- a/scripts/bundling.bash +++ b/scripts/bundling.bash @@ -11,9 +11,11 @@ function versioned_xpi_file() { # You'll want to use this with `go run ./cmd/browsh --debug --firefox.use-existing` function build_webextension_watch() { + pushd "$PROJECT_ROOT"/webext/dist || _panic "$NODE_BIN"/web-ext run \ - --firefox contrib/firefoxheadless.sh \ + --firefox ../contrib/firefoxheadless.sh \ --verbose + popd || _panic } function build_webextension_production() { diff --git a/webext/manifest.json b/webext/manifest.json index bd6dc0fc..0d26d3a1 100644 --- a/webext/manifest.json +++ b/webext/manifest.json @@ -32,6 +32,7 @@ "", "webRequest", "webRequestBlocking", - "tabs" + "tabs", + "sessions" ] } diff --git a/webext/src/background/manager.js b/webext/src/background/manager.js index 4db3153e..d1fb5b25 100644 --- a/webext/src/background/manager.js +++ b/webext/src/background/manager.js @@ -66,7 +66,6 @@ export default class extends utils.mixins(CommonMixin, TTYCommandsMixin) { _listenForTerminalMessages() { this.log("Starting to listen to TTY"); this.terminal.addEventListener("message", (event) => { - this.log("Message from terminal: " + event.data); this.handleTerminalMessage(event.data); }); } diff --git a/webext/src/background/tab_commands_mixin.js b/webext/src/background/tab_commands_mixin.js index 42b7cf1c..9c406bd4 100644 --- a/webext/src/background/tab_commands_mixin.js +++ b/webext/src/background/tab_commands_mixin.js @@ -17,6 +17,9 @@ export default (MixinBase) => case "/frame_pixels": this.sendToTerminal(`/frame_pixels,${message.slice(14)}`); break; + case "/link_hints": + this.sendToTerminal(`/link_hints,${message.slice(12)}`); + break; case "/tab_info": incoming = JSON.parse(utils.rebuildArgsToSingleArg(parts)); this._updateTabInfo(incoming); diff --git a/webext/src/background/tty_commands_mixin.js b/webext/src/background/tty_commands_mixin.js index 02fbea85..8a64610a 100644 --- a/webext/src/background/tty_commands_mixin.js +++ b/webext/src/background/tty_commands_mixin.js @@ -29,9 +29,15 @@ export default (MixinBase) => case "/switch_to_tab": this.switchToTab(parts.slice(1).join(",")); break; + case "/duplicate_tab": + this.duplicateTab(parts.slice(1).join(",")); + break; case "/remove_tab": this.removeTab(parts.slice(1).join(",")); break; + case "/restore_tab": + this.restoreTab(); + break; case "/raw_text_request": this._rawTextRequest(parts[1], parts[2], parts.slice(3).join(",")); break; @@ -175,6 +181,24 @@ export default (MixinBase) => this.tabs[id] = null; } + duplicateTab(id) { + browser.tabs.duplicate(parseInt(id)); + } + + restoreTab() { + var sessionsInfo = browser.sessions.getRecentlyClosed({ maxResults: 1 }); + sessionsInfo.then(this._restoreTab); + } + + _restoreTab(sessionsInfo) { + var mySessionInfo = sessionsInfo[0]; + if (mySessionInfo.tab) { + browser.sessions.restore(mySessionInfo.tab.sessionId); + } else { + browser.sessions.restore(mySessionInfo.window.sessionId); + } + } + // We use the `browser` object here rather than going into the actual content script // because the content script may have crashed, even never loaded. screenshotActiveTab() { diff --git a/webext/src/dom/commands_mixin.js b/webext/src/dom/commands_mixin.js index 9d6bd5c8..62afe781 100644 --- a/webext/src/dom/commands_mixin.js +++ b/webext/src/dom/commands_mixin.js @@ -1,4 +1,10 @@ import utils from "utils"; +import { Rect } from "vimium"; +import { DomUtils } from "vimium"; +import { LocalHints } from "vimium"; +import { VimiumNormal } from "vimium"; +import { MiscVimium } from "vimium"; +MiscVimium(); export default (MixinBase) => class extends MixinBase { @@ -35,19 +41,149 @@ export default (MixinBase) => break; case "/url": url = utils.rebuildArgsToSingleArg(parts); - document.location.href = url; + window.location.href = url; + break; + case "/url_up": + this.urlUp(); + break; + case "/url_root": + window.location.href = window.location.origin; break; case "/history_back": history.go(-1); break; + case "/history_forward": + history.go(1); + break; + case "/reload": + window.location.reload(); + break; case "/window_stop": window.stop(); break; + case "/find_next": + this.findNext(parts[1]); + break; + case "/find_previous": + window.find(parts[1], false, true, false, false, true, true); + break; + case "/get_link_hints": + this.getLinkHints(false); + break; + case "/get_clickable_hints": + this.getLinkHints(true); + break; + case "/focus_first_text_input": + this.focusFirstTextInput(); + break; + case "/follow_link_labeled_next": + this._followLinkLabeledNext(); + break; + case "/follow_link_labeled_previous": + this._followLinkLabeledPrevious(); + break; default: this.log("Unknown command sent to tab", message); } } + focusFirstTextInput() { + VimiumNormal.focusInput(1); + } + + //adapted vimium code + followLinkLabeledNext() { + var nextPatterns = "next,more,newer,>,›,→,»,≫,>>,weiter" || ""; + var nextStrings = nextPatterns.split(",").filter(function (s) { + return s.trim().length; + }); + return ( + VimiumNormal.findAndFollowRel("next") || + VimiumNormal.findAndFollowLink(nextStrings) + ); + } + + _followLinkLabeledNext() { + this.followLinkLabeledNext(); + } + + //adapted vimium code + followLinkLabeledPrevious() { + var previousPatterns = + "prev,previous,back,older,<,‹,←,«,≪,<<,zurück" || ""; + var previousStrings = previousPatterns.split(",").filter(function (s) { + return s.trim().length; + }); + return ( + VimiumNormal.findAndFollowRel("prev") || + VimiumNormal.findAndFollowLink(previousStrings) + ); + } + + _followLinkLabeledPrevious() { + this.followLinkLabeledPrevious(); + } + + // Eg; This goes from www.domain.com/topic/suptopic/ to www.domain.com/topic/ + urlUp() { + // this is taken from vimium's code + var url = window.location.href; + if (url[url.length - 1] === "/") { + url = url.substring(0, url.length - 1); + } + var urlsplit = url.split("/"); + // make sure we haven't hit the base domain yet + if (urlsplit.length > 3) { + urlsplit = urlsplit.slice(0, Math.max(3, urlsplit.length - 1)); + window.location.href = urlsplit.join("/"); + } + } + + getLinkHints(clickable) { + var hints = LocalHints.getLocalHints(!clickable); + var rect, bottom, top, left, right, width, height, results, result, href; + results = []; + for (let idx in hints) { + if (!hints[idx].hasOwnProperty("rect")) { + continue; + } + href = hints[idx]["href"]; + rect = hints[idx]["rect"]; + bottom = Math.round( + ((rect["bottom"] - window.scrollY) * + this.dimensions.scale_factor.height) / + 2 + ); + top = Math.round( + ((rect["top"] - window.scrollY) * + this.dimensions.scale_factor.height) / + 2 + ); + left = Math.round(rect["left"] * this.dimensions.scale_factor.width); + right = Math.round(rect["right"] * this.dimensions.scale_factor.width); + result = Rect.create(left, top, right, bottom); + result.href = href; + results.push(result); + } + this.sendMessage(`/link_hints,${JSON.stringify(results)}`); + } + + findNext(text) { + window.find(text, false, false, false, false, true, true); + //var s = window.getSelection(); + //var oRange = s.getRangeAt(0); //get the text range + //var oRect = oRange.getBoundingClientRect(); + //window.scrollTo(400, 20000); + this.dimensions.y_scroll = Math.round( + window.scrollY * this.dimensions.scale_factor.height + ); + this.dimensions.x_scroll = Math.round( + window.scrollX * this.dimensions.scale_factor.width + ); + this.dimensions.update(); + this._mightSendBigFrames(); + } + _launch() { const mode = this.config.http_server_mode_type; if (mode.includes("raw_text_")) { @@ -119,9 +255,25 @@ export default (MixinBase) => _handleMouse(input) { switch (input.button) { case 1: - this._mouseAction("mousemove", input.mouse_x, input.mouse_y); + var y_hack = false; + if (input.hasOwnProperty("y_hack")) { + y_hack = true; + } + this._mouseAction( + "mousemove", + input.mouse_x, + input.mouse_y, + 0, + y_hack + ); if (!this._mousedown) { - this._mouseAction("mousedown", input.mouse_x, input.mouse_y); + this._mouseAction( + "mousedown", + input.mouse_x, + input.mouse_y, + 0, + y_hack + ); setTimeout(() => { this.sendSmallTextFrame(); }, 500); @@ -129,10 +281,26 @@ export default (MixinBase) => this._mousedown = true; break; case 0: - this._mouseAction("mousemove", input.mouse_x, input.mouse_y); + var y_hack = false; + if (input.hasOwnProperty("y_hack")) { + y_hack = true; + } + this._mouseAction( + "mousemove", + input.mouse_x, + input.mouse_y, + 0, + y_hack + ); if (this._mousedown) { - this._mouseAction("click", input.mouse_x, input.mouse_y); - this._mouseAction("mouseup", input.mouse_x, input.mouse_y); + this._mouseAction("click", input.mouse_x, input.mouse_y, 0, y_hack); + this._mouseAction( + "mouseup", + input.mouse_x, + input.mouse_y, + 0, + y_hack + ); } this._mousedown = false; break; @@ -186,12 +354,19 @@ export default (MixinBase) => } } - _mouseAction(type, x, y) { - const [dom_x, dom_y] = this._getDOMCoordsFromMouseCoords(x, y); + _mouseAction(type, x, y, button, y_hack = false) { + let [dom_x, dom_y] = this._getDOMCoordsFromMouseCoords(x, y); + if (y_hack) { + const [dom_x2, dom_y2] = this._getDOMCoordsFromMouseCoords(x, y + 1); + dom_y = (dom_y + dom_y2) / 2; + } const element = document.elementFromPoint( dom_x - window.scrollX, dom_y - window.scrollY ); + if (!element) { + return; + } element.focus(); var clickEvent = document.createEvent("MouseEvents"); clickEvent.initMouseEvent( @@ -208,7 +383,7 @@ export default (MixinBase) => false, false, false, - 0, + button, null ); element.dispatchEvent(clickEvent); diff --git a/webext/src/dom/manager.js b/webext/src/dom/manager.js index 914c4e93..d4ad83c9 100644 --- a/webext/src/dom/manager.js +++ b/webext/src/dom/manager.js @@ -129,6 +129,7 @@ export default class extends utils.mixins(CommonMixin, CommandsMixin) { _setupInteractiveMode() { this._setupDebouncedFunctions(); this._startMutationObserver(); + // TODO: wait until body exists this.sendAllBigFrames(); // TODO: // Disabling CSS transitions is not easy, many pages won't even render @@ -211,15 +212,32 @@ export default class extends utils.mixins(CommonMixin, CommandsMixin) { let target = document.querySelector("body"); let observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { + if (!target) { + const nodes = Array.from(mutation.addedNodes); + for (let node of nodes) { + if (node.matches && node.matches("body")) { + target = node; + observer.observe(target, { + subtree: true, + characterData: true, + childList: true, + }); + break; + } + } + } this.log("!!MUTATION!!", mutation); this._debouncedSmallTextFrame(); }); }); - observer.observe(target, { - subtree: true, - characterData: true, - childList: true, - }); + + if (target) { + observer.observe(target, { + subtree: true, + characterData: true, + childList: true, + }); + } } _listenForBackgroundMessages() { diff --git a/webext/src/dom/text_builder.js b/webext/src/dom/text_builder.js index 9c4db665..493d2924 100644 --- a/webext/src/dom/text_builder.js +++ b/webext/src/dom/text_builder.js @@ -78,6 +78,9 @@ export default class extends utils.mixins(CommonMixin, SerialiseMixin) { // Search through every node in the DOM looking for displayable text. __getTextNodes() { + if (!document.body) { + return; + } this._text_nodes = []; const walker = document.createTreeWalker( document.body, diff --git a/webext/src/vimium.js b/webext/src/vimium.js new file mode 100644 index 00000000..f2f4a07e --- /dev/null +++ b/webext/src/vimium.js @@ -0,0 +1,1249 @@ +// The code in this file was copied and adapted from vimium's codebase. +// It's MIT licensed, so there should be no problem mixing it with +// Browsh's codebase or even relicensing the code as LGPLv2. +// +// Here is the list of changes made to vimium's original code in order +// to better fullfill the needs of Browsh: +// +// In line 11 of getLocalHints the following code was added: +// +// if (requireHref && element.href && visibleElement.length > 0) { +// visibleElement[0]["href"] = element.href; +// } +// +// Also in getLocalHints the following outcommented code was replaced +// with the code preceding it in order to prevent Firefox from crashing: +// +// try { +// var testextend = extend(visibleElement, { rect: rects[0] }); +// nonOverlappingElements.push(testextend); +// } catch(error) { +// nonOverlappingElements.push(visibleElement); +// } +// /*nonOverlappingElements.push(extend(visibleElement, { +// rect: rects[0] +// }));*/ +// +// The following lines just before the return statement in getLocalHints were +// commented out, because we're currently not using this functionality and the +// settings for it simply don't exist in Browsh (yet). +// +// /*if (Settings.get("filterLinkHints")) { +// for (m = 0, len3 = localHints.length; m < len3; m++) { +// hint = localHints[m]; +// extend(hint, this.generateLinkText(hint)); +// } +// }*/ +// +// In multiple places Utils.isFirefox() calls were commented out or replaced with true, +// because Browsh is currently assuming that it is being run in Firefox. + +var Rect = { + create: function (x1, y1, x2, y2) { + return { + bottom: y2, + top: y1, + left: x1, + right: x2, + width: x2 - x1, + height: y2 - y1, + }; + }, + copy: function (rect) { + return { + bottom: rect.bottom, + top: rect.top, + left: rect.left, + right: rect.right, + width: rect.width, + height: rect.height, + }; + }, + translate: function (rect, x, y) { + if (x == null) { + x = 0; + } + if (y == null) { + y = 0; + } + return { + bottom: rect.bottom + y, + top: rect.top + y, + left: rect.left + x, + right: rect.right + x, + width: rect.width, + height: rect.height, + }; + }, + subtract: function (rect1, rect2) { + var rects; + rect2 = this.create( + Math.max(rect1.left, rect2.left), + Math.max(rect1.top, rect2.top), + Math.min(rect1.right, rect2.right), + Math.min(rect1.bottom, rect2.bottom) + ); + if (rect2.width < 0 || rect2.height < 0) { + return [Rect.copy(rect1)]; + } + rects = [ + this.create(rect1.left, rect1.top, rect2.left, rect2.top), + this.create(rect2.left, rect1.top, rect2.right, rect2.top), + this.create(rect2.right, rect1.top, rect1.right, rect2.top), + this.create(rect1.left, rect2.top, rect2.left, rect2.bottom), + this.create(rect2.right, rect2.top, rect1.right, rect2.bottom), + this.create(rect1.left, rect2.bottom, rect2.left, rect1.bottom), + this.create(rect2.left, rect2.bottom, rect2.right, rect1.bottom), + this.create(rect2.right, rect2.bottom, rect1.right, rect1.bottom), + ]; + return rects.filter(function (rect) { + return rect.height > 0 && rect.width > 0; + }); + }, + intersects: function (rect1, rect2) { + return ( + rect1.right > rect2.left && + rect1.left < rect2.right && + rect1.bottom > rect2.top && + rect1.top < rect2.bottom + ); + }, + intersectsStrict: function (rect1, rect2) { + return ( + rect1.right >= rect2.left && + rect1.left <= rect2.right && + rect1.bottom >= rect2.top && + rect1.top <= rect2.bottom + ); + }, + equals: function (rect1, rect2) { + var i, len, property, ref; + ref = ["top", "bottom", "left", "right", "width", "height"]; + for (i = 0, len = ref.length; i < len; i++) { + property = ref[i]; + if (rect1[property] !== rect2[property]) { + return false; + } + } + return true; + }, + intersect: function (rect1, rect2) { + return this.create( + Math.max(rect1.left, rect2.left), + Math.max(rect1.top, rect2.top), + Math.min(rect1.right, rect2.right), + Math.min(rect1.bottom, rect2.bottom) + ); + }, +}; + +var DomUtils = { + documentReady: function () { + var callbacks, isReady, onDOMContentLoaded, ref; + (ref = [document.readyState !== "loading", []]), + (isReady = ref[0]), + (callbacks = ref[1]); + if (!isReady) { + window.addEventListener( + "DOMContentLoaded", + (onDOMContentLoaded = forTrusted(function () { + var callback, i, len; + window.removeEventListener("DOMContentLoaded", onDOMContentLoaded); + isReady = true; + for (i = 0, len = callbacks.length; i < len; i++) { + callback = callbacks[i]; + callback(); + } + return (callbacks = null); + })) + ); + } + return function (callback) { + if (isReady) { + return callback(); + } else { + return callbacks.push(callback); + } + }; + }, + getVisibleClientRect: function (element, testChildren) { + var child, + childClientRect, + clientRect, + clientRects, + computedStyle, + i, + isInlineZeroHeight, + j, + len, + len1, + ref, + ref1; + if (testChildren == null) { + testChildren = false; + } + clientRects = (function () { + var i, len, ref, results; + ref = element.getClientRects(); + results = []; + for (i = 0, len = ref.length; i < len; i++) { + clientRect = ref[i]; + results.push(Rect.copy(clientRect)); + } + return results; + })(); + isInlineZeroHeight = function () { + var elementComputedStyle, isInlineZeroFontSize; + elementComputedStyle = window.getComputedStyle(element, null); + isInlineZeroFontSize = + 0 === + elementComputedStyle.getPropertyValue("display").indexOf("inline") && + elementComputedStyle.getPropertyValue("font-size") === "0px"; + isInlineZeroHeight = function () { + return isInlineZeroFontSize; + }; + return isInlineZeroFontSize; + }; + for (i = 0, len = clientRects.length; i < len; i++) { + clientRect = clientRects[i]; + if ((clientRect.width === 0 || clientRect.height === 0) && testChildren) { + ref = element.children; + for (j = 0, len1 = ref.length; j < len1; j++) { + child = ref[j]; + computedStyle = window.getComputedStyle(child, null); + if ( + computedStyle.getPropertyValue("float") === "none" && + !( + (ref1 = computedStyle.getPropertyValue("position")) === + "absolute" || ref1 === "fixed" + ) && + !( + clientRect.height === 0 && + isInlineZeroHeight() && + 0 === computedStyle.getPropertyValue("display").indexOf("inline") + ) + ) { + continue; + } + childClientRect = this.getVisibleClientRect(child, true); + if ( + childClientRect === null || + childClientRect.width < 3 || + childClientRect.height < 3 + ) { + continue; + } + return childClientRect; + } + } else { + clientRect = this.cropRectToVisible(clientRect); + if ( + clientRect === null || + clientRect.width < 3 || + clientRect.height < 3 + ) { + continue; + } + computedStyle = window.getComputedStyle(element, null); + if (computedStyle.getPropertyValue("visibility") !== "visible") { + continue; + } + return clientRect; + } + } + return null; + }, + cropRectToVisible: function (rect) { + var boundedRect; + boundedRect = Rect.create( + Math.max(rect.left, 0), + Math.max(rect.top, 0), + rect.right, + rect.bottom + ); + if ( + boundedRect.top >= window.innerHeight - 4 || + boundedRect.left >= window.innerWidth - 4 + ) { + return null; + } else { + return boundedRect; + } + }, + getClientRectsForAreas: function (imgClientRect, areas) { + var area, + coords, + diff, + i, + len, + r, + rect, + rects, + ref, + shape, + x, + x1, + x2, + y, + y1, + y2; + rects = []; + for (i = 0, len = areas.length; i < len; i++) { + area = areas[i]; + coords = area.coords.split(",").map(function (coord) { + return parseInt(coord, 10); + }); + shape = area.shape.toLowerCase(); + if (shape === "rect" || shape === "rectangle") { + (x1 = coords[0]), (y1 = coords[1]), (x2 = coords[2]), (y2 = coords[3]); + } else if (shape === "circle" || shape === "circ") { + (x = coords[0]), (y = coords[1]), (r = coords[2]); + diff = r / Math.sqrt(2); + x1 = x - diff; + x2 = x + diff; + y1 = y - diff; + y2 = y + diff; + } else if (shape === "default") { + (ref = [0, 0, imgClientRect.width, imgClientRect.height]), + (x1 = ref[0]), + (y1 = ref[1]), + (x2 = ref[2]), + (y2 = ref[3]); + } else { + (x1 = coords[0]), (y1 = coords[1]), (x2 = coords[2]), (y2 = coords[3]); + } + rect = Rect.translate( + Rect.create(x1, y1, x2, y2), + imgClientRect.left, + imgClientRect.top + ); + rect = this.cropRectToVisible(rect); + if (rect && !isNaN(rect.top)) { + rects.push({ + element: area, + rect: rect, + }); + } + } + return rects; + }, + isSelectable: function (element) { + var unselectableTypes; + if (!(element instanceof Element)) { + return false; + } + unselectableTypes = [ + "button", + "checkbox", + "color", + "file", + "hidden", + "image", + "radio", + "reset", + "submit", + ]; + return ( + (element.nodeName.toLowerCase() === "input" && + unselectableTypes.indexOf(element.type) === -1) || + element.nodeName.toLowerCase() === "textarea" || + element.isContentEditable + ); + }, + getViewportTopLeft: function () { + var box, clientLeft, clientTop, marginLeft, marginTop, rect, style; + box = document.documentElement; + style = getComputedStyle(box); + rect = box.getBoundingClientRect(); + if ( + style.position === "static" && + !/content|paint|strict/.test(style.contain || "") + ) { + marginTop = parseInt(style.marginTop); + marginLeft = parseInt(style.marginLeft); + return { + top: -rect.top + marginTop, + left: -rect.left + marginLeft, + }; + } else { + //if (Utils.isFirefox()) + if (true) { + clientTop = parseInt(style.borderTopWidth); + clientLeft = parseInt(style.borderLeftWidth); + } else { + (clientTop = box.clientTop), (clientLeft = box.clientLeft); + } + return { + top: -rect.top - clientTop, + left: -rect.left - clientLeft, + }; + } + }, + makeXPath: function (elementArray) { + var element, i, len, xpath; + xpath = []; + for (i = 0, len = elementArray.length; i < len; i++) { + element = elementArray[i]; + xpath.push(".//" + element, ".//xhtml:" + element); + } + return xpath.join(" | "); + }, + evaluateXPath: function (xpath, resultType) { + var contextNode, namespaceResolver; + contextNode = document.webkitIsFullScreen + ? document.webkitFullscreenElement + : document.documentElement; + namespaceResolver = function (namespace) { + if (namespace === "xhtml") { + return "http://www.w3.org/1999/xhtml"; + } else { + return null; + } + }; + return document.evaluate( + xpath, + contextNode, + namespaceResolver, + resultType, + null + ); + }, + simulateClick: function (element, modifiers) { + var defaultActionShouldTrigger, event, eventSequence, i, len, results; + if (modifiers == null) { + modifiers = {}; + } + eventSequence = ["mouseover", "mousedown", "mouseup", "click"]; + results = []; + for (i = 0, len = eventSequence.length; i < len; i++) { + event = eventSequence[i]; + defaultActionShouldTrigger = + /*Utils.isFirefox() &&*/ Object.keys(modifiers).length === 0 && + event === "click" && + element.target === "_blank" && + element.href && + !element.hasAttribute("onclick") && + !element.hasAttribute("_vimium-has-onclick-listener") + ? true + : this.simulateMouseEvent(event, element, modifiers); + if ( + event === "click" && + defaultActionShouldTrigger /*&& Utils.isFirefox()*/ + ) { + if (0 < Object.keys(modifiers).length || element.target === "_blank") { + DomUtils.simulateClickDefaultAction(element, modifiers); + } + } + results.push(defaultActionShouldTrigger); + } + return results; + }, + simulateMouseEvent: (function () { + var lastHoveredElement; + lastHoveredElement = void 0; + return function (event, element, modifiers) { + var mouseEvent; + if (modifiers == null) { + modifiers = {}; + } + if (event === "mouseout") { + if (element == null) { + element = lastHoveredElement; + } + lastHoveredElement = void 0; + if (element == null) { + return; + } + } else if (event === "mouseover") { + this.simulateMouseEvent("mouseout", void 0, modifiers); + lastHoveredElement = element; + } + mouseEvent = document.createEvent("MouseEvents"); + mouseEvent.initMouseEvent( + event, + true, + true, + window, + 1, + 0, + 0, + 0, + 0, + modifiers.ctrlKey, + modifiers.altKey, + modifiers.shiftKey, + modifiers.metaKey, + 0, + null + ); + return element.dispatchEvent(mouseEvent); + }; + })(), + simulateClickDefaultAction: function (element, modifiers) { + var altKey, ctrlKey, metaKey, newTabModifier, ref, shiftKey; + if (modifiers == null) { + modifiers = {}; + } + if ( + !( + ((ref = element.tagName) != null ? ref.toLowerCase() : void 0) === + "a" && element.href != null + ) + ) { + return; + } + (ctrlKey = modifiers.ctrlKey), + (shiftKey = modifiers.shiftKey), + (metaKey = modifiers.metaKey), + (altKey = modifiers.altKey); + if (KeyboardUtils.platform === "Mac") { + newTabModifier = metaKey === true && ctrlKey === false; + } else { + newTabModifier = metaKey === false && ctrlKey === true; + } + if (newTabModifier) { + chrome.runtime.sendMessage({ + handler: "openUrlInNewTab", + url: element.href, + active: shiftKey === true, + }); + } else if ( + shiftKey === true && + metaKey === false && + ctrlKey === false && + altKey === false + ) { + chrome.runtime.sendMessage({ + handler: "openUrlInNewWindow", + url: element.href, + }); + } else if (element.target === "_blank") { + chrome.runtime.sendMessage({ + handler: "openUrlInNewTab", + url: element.href, + active: true, + }); + } + }, +}; + +var LocalHints = { + getVisibleClickable: function (element) { + var actionName, + areas, + areasAndRects, + base1, + clientRect, + contentEditable, + eventType, + i, + imgClientRects, + isClickable, + jsactionRule, + jsactionRules, + len, + map, + mapName, + namespace, + onlyHasTabIndex, + possibleFalsePositive, + reason, + ref, + ref1, + ref10, + ref11, + ref12, + ref2, + ref3, + ref4, + ref5, + ref6, + ref7, + ref8, + ref9, + role, + ruleSplit, + tabIndex, + tabIndexValue, + tagName, + visibleElements, + slice; + tagName = + (ref = + typeof (base1 = element.tagName).toLowerCase === "function" + ? base1.toLowerCase() + : void 0) != null + ? ref + : ""; + isClickable = false; + onlyHasTabIndex = false; + possibleFalsePositive = false; + visibleElements = []; + reason = null; + slice = [].slice; + if (tagName === "img") { + mapName = element.getAttribute("usemap"); + if (mapName) { + imgClientRects = element.getClientRects(); + mapName = mapName.replace(/^#/, "").replace('"', '\\"'); + map = document.querySelector('map[name="' + mapName + '"]'); + if (map && imgClientRects.length > 0) { + areas = map.getElementsByTagName("area"); + areasAndRects = DomUtils.getClientRectsForAreas( + imgClientRects[0], + areas + ); + visibleElements.push.apply(visibleElements, areasAndRects); + } + } + } + if ( + (ref1 = + (ref2 = element.getAttribute("aria-hidden")) != null + ? ref2.toLowerCase() + : void 0) === "" || + ref1 === "true" || + (ref3 = + (ref4 = element.getAttribute("aria-disabled")) != null + ? ref4.toLowerCase() + : void 0) === "" || + ref3 === "true" + ) { + return []; + } + if (this.checkForAngularJs == null) { + this.checkForAngularJs = (function () { + var angularElements, + i, + k, + len, + len1, + ngAttributes, + prefix, + ref5, + ref6, + separator; + angularElements = document.getElementsByClassName("ng-scope"); + if (angularElements.length === 0) { + return function () { + return false; + }; + } else { + ngAttributes = []; + ref5 = ["", "data-", "x-"]; + for (i = 0, len = ref5.length; i < len; i++) { + prefix = ref5[i]; + ref6 = ["-", ":", "_"]; + for (k = 0, len1 = ref6.length; k < len1; k++) { + separator = ref6[k]; + ngAttributes.push(prefix + "ng" + separator + "click"); + } + } + return function (element) { + var attribute, l, len2; + for (l = 0, len2 = ngAttributes.length; l < len2; l++) { + attribute = ngAttributes[l]; + if (element.hasAttribute(attribute)) { + return true; + } + } + return false; + }; + } + })(); + } + isClickable || (isClickable = this.checkForAngularJs(element)); + if ( + element.hasAttribute("onclick") || + ((role = element.getAttribute("role")) && + ((ref5 = role.toLowerCase()) === "button" || + ref5 === "tab" || + ref5 === "link" || + ref5 === "checkbox" || + ref5 === "menuitem" || + ref5 === "menuitemcheckbox" || + ref5 === "menuitemradio")) || + ((contentEditable = element.getAttribute("contentEditable")) && + ((ref6 = contentEditable.toLowerCase()) === "" || + ref6 === "contenteditable" || + ref6 === "true")) + ) { + isClickable = true; + } + if (!isClickable && element.hasAttribute("jsaction")) { + jsactionRules = element.getAttribute("jsaction").split(";"); + for (i = 0, len = jsactionRules.length; i < len; i++) { + jsactionRule = jsactionRules[i]; + ruleSplit = jsactionRule.trim().split(":"); + if (1 <= (ref7 = ruleSplit.length) && ref7 <= 2) { + (ref8 = + ruleSplit.length === 1 + ? ["click"].concat(slice.call(ruleSplit[0].trim().split(".")), [ + "_", + ]) + : [ruleSplit[0]].concat( + slice.call(ruleSplit[1].trim().split(".")), + ["_"] + )), + (eventType = ref8[0]), + (namespace = ref8[1]), + (actionName = ref8[2]); + isClickable || + (isClickable = + eventType === "click" && + namespace !== "none" && + actionName !== "_"); + } + } + } + switch (tagName) { + case "a": + isClickable = true; + break; + case "textarea": + isClickable || (isClickable = !element.disabled && !element.readOnly); + break; + case "input": + isClickable || + (isClickable = !( + ((ref9 = element.getAttribute("type")) != null + ? ref9.toLowerCase() + : void 0) === "hidden" || + element.disabled || + (element.readOnly && DomUtils.isSelectable(element)) + )); + break; + case "button": + case "select": + isClickable || (isClickable = !element.disabled); + break; + case "label": + isClickable || + (isClickable = + element.control != null && + !element.control.disabled && + this.getVisibleClickable(element.control).length === 0); + break; + case "body": + isClickable || + (isClickable = + element === document.body && + !windowIsFocused() && + window.innerWidth > 3 && + window.innerHeight > 3 && + ((ref10 = document.body) != null + ? ref10.tagName.toLowerCase() + : void 0) !== "frameset" + ? (reason = "Frame.") + : void 0); + //isClickable || (isClickable = element === document.body && windowIsFocused() && Scroller.isScrollableElement(element) ? reason = "Scroll." : void 0); + break; + case "img": + isClickable || + (isClickable = + (ref11 = element.style.cursor) === "zoom-in" || + ref11 === "zoom-out"); + break; + case "div": + case "ol": + case "ul": + //isClickable || (isClickable = element.clientHeight < element.scrollHeight && Scroller.isScrollableElement(element) ? reason = "Scroll." : void 0); + break; + case "details": + isClickable = true; + reason = "Open."; + } + if ( + !isClickable && + 0 <= + ((ref12 = element.getAttribute("class")) != null + ? ref12.toLowerCase().indexOf("button") + : void 0) + ) { + possibleFalsePositive = isClickable = true; + } + tabIndexValue = element.getAttribute("tabindex"); + tabIndex = tabIndexValue === "" ? 0 : parseInt(tabIndexValue); + if (!(isClickable || isNaN(tabIndex) || tabIndex < 0)) { + isClickable = onlyHasTabIndex = true; + } + if (isClickable) { + clientRect = DomUtils.getVisibleClientRect(element, true); + if (clientRect !== null) { + visibleElements.push({ + element: element, + rect: clientRect, + secondClassCitizen: onlyHasTabIndex, + possibleFalsePositive: possibleFalsePositive, + reason: reason, + }); + } + } + return visibleElements; + }, + getLocalHints: function (requireHref) { + var descendantsToCheck, + element, + elements, + hint, + i, + k, + l, + left, + len, + len1, + len2, + len3, + localHints, + m, + negativeRect, + nonOverlappingElements, + position, + rects, + ref, + ref1, + top, + visibleElement, + visibleElements; + if (!document.documentElement) { + return []; + } + elements = document.documentElement.getElementsByTagName("*"); + visibleElements = []; + for (i = 0, len = elements.length; i < len; i++) { + element = elements[i]; + if (!(requireHref && !element.href)) { + visibleElement = this.getVisibleClickable(element); + if (requireHref && element.href && visibleElement.length > 0) { + visibleElement[0]["href"] = element.href; + } + visibleElements.push.apply(visibleElements, visibleElement); + } + } + visibleElements = visibleElements.reverse(); + descendantsToCheck = [1, 2, 3]; + visibleElements = (function () { + var k, len1, results; + results = []; + for ( + position = k = 0, len1 = visibleElements.length; + k < len1; + position = ++k + ) { + element = visibleElements[position]; + if ( + element.possibleFalsePositive && + (function () { + var _, candidateDescendant, index, l, len2; + index = Math.max(0, position - 6); + while (index < position) { + candidateDescendant = visibleElements[index].element; + for (l = 0, len2 = descendantsToCheck.length; l < len2; l++) { + _ = descendantsToCheck[l]; + candidateDescendant = + candidateDescendant != null + ? candidateDescendant.parentElement + : void 0; + if (candidateDescendant === element.element) { + return true; + } + } + index += 1; + } + return false; + })() + ) { + continue; + } + results.push(element); + } + return results; + })(); + localHints = nonOverlappingElements = []; + while ((visibleElement = visibleElements.pop())) { + rects = [visibleElement.rect]; + for (k = 0, len1 = visibleElements.length; k < len1; k++) { + negativeRect = visibleElements[k].rect; + rects = (ref = []).concat.apply( + ref, + rects.map(function (rect) { + return Rect.subtract(rect, negativeRect); + }) + ); + } + if (rects.length > 0) { + try { + var testextend = extend(visibleElement, { rect: rects[0] }); + nonOverlappingElements.push(testextend); + } catch (error) { + nonOverlappingElements.push(visibleElement); + } + /*nonOverlappingElements.push(extend(visibleElement, { + rect: rects[0] + }));*/ + } else { + if (!visibleElement.secondClassCitizen) { + nonOverlappingElements.push(visibleElement); + } + } + } + (ref1 = DomUtils.getViewportTopLeft()), + (top = ref1.top), + (left = ref1.left); + for (l = 0, len2 = nonOverlappingElements.length; l < len2; l++) { + hint = nonOverlappingElements[l]; + hint.rect.top += top; + hint.rect.left += left; + } + /*if (Settings.get("filterLinkHints")) { + for (m = 0, len3 = localHints.length; m < len3; m++) { + hint = localHints[m]; + extend(hint, this.generateLinkText(hint)); + } + }*/ + return localHints; + }, + generateLinkText: function (hint) { + var element, linkText, nodeName, ref, showLinkText; + element = hint.element; + linkText = ""; + showLinkText = false; + nodeName = element.nodeName.toLowerCase(); + if (nodeName === "input") { + if (element.labels != null && element.labels.length > 0) { + linkText = element.labels[0].textContent.trim(); + if (linkText[linkText.length - 1] === ":") { + linkText = linkText.slice(0, linkText.length - 1); + } + showLinkText = true; + } else if ( + ((ref = element.getAttribute("type")) != null + ? ref.toLowerCase() + : void 0) === "file" + ) { + linkText = "Choose File"; + } else if (element.type !== "password") { + linkText = element.value; + if (!linkText && "placeholder" in element) { + linkText = element.placeholder; + } + } + } else if ( + nodeName === "a" && + !element.textContent.trim() && + element.firstElementChild && + element.firstElementChild.nodeName.toLowerCase() === "img" + ) { + linkText = + element.firstElementChild.alt || element.firstElementChild.title; + if (linkText) { + showLinkText = true; + } + } else if (hint.reason != null) { + linkText = hint.reason; + showLinkText = true; + } else if (0 < element.textContent.length) { + linkText = element.textContent.slice(0, 256); + } else if (element.hasAttribute("title")) { + linkText = element.getAttribute("title"); + } else { + linkText = element.innerHTML.slice(0, 256); + } + return { + linkText: linkText.trim(), + showLinkText: showLinkText, + }; + }, +}; + +var VimiumNormal = { + followLink: function (linkElement) { + if (linkElement.nodeName.toLowerCase() === "link") { + return (window.location.href = linkElement.href); + } else { + linkElement.scrollIntoView(); + return DomUtils.simulateClick(linkElement); + } + }, + findAndFollowLink: function (linkStrings) { + var boundingClientRect, + candidateLink, + candidateLinks, + computedStyle, + exactWordRegex, + i, + j, + k, + l, + len, + len1, + len2, + len3, + link, + linkMatches, + linkString, + links, + linksXPath, + m, + n, + ref, + ref1; + linksXPath = DomUtils.makeXPath([ + "a", + "*[@onclick or @role='link' or contains(@class, 'button')]", + ]); + links = DomUtils.evaluateXPath( + linksXPath, + XPathResult.ORDERED_NODE_SNAPSHOT_TYPE + ); + candidateLinks = []; + for (i = j = ref = links.snapshotLength - 1; j >= 0; i = j += -1) { + link = links.snapshotItem(i); + boundingClientRect = link.getBoundingClientRect(); + if (boundingClientRect.width === 0 || boundingClientRect.height === 0) { + continue; + } + computedStyle = window.getComputedStyle(link, null); + if ( + computedStyle.getPropertyValue("visibility") !== "visible" || + computedStyle.getPropertyValue("display") === "none" + ) { + continue; + } + linkMatches = false; + for (k = 0, len = linkStrings.length; k < len; k++) { + linkString = linkStrings[k]; + if ( + link.innerText.toLowerCase().indexOf(linkString) !== -1 || + 0 <= + ((ref1 = link.value) != null + ? typeof ref1.indexOf === "function" + ? ref1.indexOf(linkString) + : void 0 + : void 0) + ) { + linkMatches = true; + break; + } + } + if (!linkMatches) { + continue; + } + candidateLinks.push(link); + } + if (candidateLinks.length === 0) { + return; + } + for (l = 0, len1 = candidateLinks.length; l < len1; l++) { + link = candidateLinks[l]; + link.wordCount = link.innerText.trim().split(/\s+/).length; + } + candidateLinks.forEach(function (a, i) { + return (a.originalIndex = i); + }); + candidateLinks = candidateLinks + .sort(function (a, b) { + if (a.wordCount === b.wordCount) { + return a.originalIndex - b.originalIndex; + } else { + return a.wordCount - b.wordCount; + } + }) + .filter(function (a) { + return a.wordCount <= candidateLinks[0].wordCount + 1; + }); + for (m = 0, len2 = linkStrings.length; m < len2; m++) { + linkString = linkStrings[m]; + exactWordRegex = + /\b/.test(linkString[0]) || /\b/.test(linkString[linkString.length - 1]) + ? new RegExp("\\b" + linkString + "\\b", "i") + : new RegExp(linkString, "i"); + for (n = 0, len3 = candidateLinks.length; n < len3; n++) { + candidateLink = candidateLinks[n]; + if ( + exactWordRegex.test(candidateLink.innerText) || + (candidateLink.value && exactWordRegex.test(candidateLink.value)) + ) { + this.followLink(candidateLink); + return true; + } + } + } + return false; + }, + findAndFollowRel: function (value) { + var element, elements, j, k, len, len1, relTags, tag; + relTags = ["link", "a", "area"]; + for (j = 0, len = relTags.length; j < len; j++) { + tag = relTags[j]; + elements = document.getElementsByTagName(tag); + for (k = 0, len1 = elements.length; k < len1; k++) { + element = elements[k]; + if ( + element.hasAttribute("rel") && + element.rel.toLowerCase() === value + ) { + this.followLink(element); + return true; + } + } + } + }, + textInputXPath: function () { + var inputElements, textInputTypes; + textInputTypes = [ + "text", + "search", + "email", + "url", + "number", + "password", + "date", + "tel", + ]; + inputElements = [ + "input[" + + "(" + + textInputTypes + .map(function (type) { + return '@type="' + type + '"'; + }) + .join(" or ") + + "or not(@type))" + + " and not(@disabled or @readonly)]", + "textarea", + "*[@contenteditable='' or translate(@contenteditable, 'TRUE', 'true')='true']", + ]; + return typeof DomUtils !== "undefined" && DomUtils !== null + ? DomUtils.makeXPath(inputElements) + : void 0; + }, + focusInput: function (count) { + var element, + elements, + hint, + hints, + i, + recentlyFocusedElement, + resultSet, + selectedInputIndex, + tuple, + visibleInputs; + resultSet = DomUtils.evaluateXPath( + textInputXPath, + XPathResult.ORDERED_NODE_SNAPSHOT_TYPE + ); + visibleInputs = (function () { + var j, ref, results; + results = []; + for (i = j = 0, ref = resultSet.snapshotLength; j < ref; i = j += 1) { + element = resultSet.snapshotItem(i); + if (!DomUtils.getVisibleClientRect(element, true)) { + continue; + } + results.push({ + element: element, + index: i, + rect: Rect.copy(element.getBoundingClientRect()), + }); + } + return results; + })(); + visibleInputs.sort(function (arg, arg1) { + var element1, element2, i1, i2, tabDifference; + (element1 = arg.element), (i1 = arg.index); + (element2 = arg1.element), (i2 = arg1.index); + if (element1.tabIndex > 0) { + if (element2.tabIndex > 0) { + tabDifference = element1.tabIndex - element2.tabIndex; + if (tabDifference !== 0) { + return tabDifference; + } else { + return i1 - i2; + } + } else { + return -1; + } + } else if (element2.tabIndex > 0) { + return 1; + } else { + return i1 - i2; + } + }); + if (visibleInputs.length === 0) { + HUD.showForDuration("There are no inputs to focus.", 1000); + return; + } + recentlyFocusedElement = lastFocusedInput(); + selectedInputIndex = + count === 1 + ? ((elements = visibleInputs.map(function (visibleInput) { + return visibleInput.element; + })), + Math.max(0, elements.indexOf(recentlyFocusedElement))) + : Math.min(count, visibleInputs.length) - 1; + hints = (function () { + var j, len, results; + results = []; + for (j = 0, len = visibleInputs.length; j < len; j++) { + tuple = visibleInputs[j]; + hint = DomUtils.createElement("div"); + hint.className = "vimiumReset internalVimiumInputHint vimiumInputHint"; + hint.style.left = tuple.rect.left - 1 + window.scrollX + "px"; + hint.style.top = tuple.rect.top - 1 + window.scrollY + "px"; + hint.style.width = tuple.rect.width + "px"; + hint.style.height = tuple.rect.height + "px"; + results.push(hint); + } + return results; + })(); + return new FocusSelector(hints, visibleInputs, selectedInputIndex); + }, +}; + +export function MiscVimium() { + if (window.forTrusted == null) { + window.forTrusted = function (handler) { + return function (event) { + if (event != null ? event.isTrusted : void 0) { + return handler.apply(this, arguments); + } else { + return true; + } + }; + }; + } + window.windowIsFocused = function () { + var windowHasFocus; + windowHasFocus = null; + DomUtils.documentReady(function () { + return (windowHasFocus = document.hasFocus()); + }); + window.addEventListener( + "focus", + forTrusted(function (event) { + if (event.target === window) { + windowHasFocus = true; + } + return true; + }) + ); + window.addEventListener( + "blur", + forTrusted(function (event) { + if (event.target === window) { + windowHasFocus = false; + } + return true; + }) + ); + return function () { + return windowHasFocus; + }; + }; +} + +export { Rect }; +export { DomUtils }; +export { LocalHints }; +export { VimiumNormal };