Very preliminary work for bringing hints back
authorVirgil Dupras <hsoft@hardcoded.net>
Mon, 24 Apr 2017 00:53:34 +0000 (20:53 -0400)
committerDaniel Carl <danielcarl@gmx.de>
Sat, 6 May 2017 23:31:56 +0000 (01:31 +0200)
src/main.c
src/normal.c
src/scripts/hints.js [new file with mode: 0644]

index 7ab6572..cace512 100644 (file)
@@ -1652,6 +1652,7 @@ static WebKitWebView *webview_new(Client *c, WebKitWebView *webview)
 {
     WebKitWebView *new;
     WebKitUserContentManager *ucm;
+    WebKitUserScript *script;
 
     /* create a new webview */
     if (webview) {
@@ -1679,6 +1680,13 @@ static WebKitWebView *webview_new(Client *c, WebKitWebView *webview)
 
     g_signal_connect(webkit_web_context_get_default(), "download-started", G_CALLBACK(on_webctx_download_started), c);
 
+    /* Inject the global hints script. */
+    script = webkit_user_script_new(HINTS,
+            WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
+            WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_END, NULL, NULL);
+    webkit_user_content_manager_add_script(ucm, script);
+    webkit_user_script_unref(script);
+
     /* Setup script message handlers. */
     webkit_user_content_manager_register_script_message_handler(ucm, "focus");
     g_signal_connect(ucm, "script-message-received::focus", G_CALLBACK(on_script_message_focus), c);
index 156ef52..1dc3261 100644 (file)
@@ -408,7 +408,9 @@ static VbResult normal_ex(Client *c, const NormalCmdInfo *info)
     if (info->key == 'F') {
         vb_enter_prompt(c, 'c', ";t", TRUE);
     } else if (info->key == 'f') {
-        vb_enter_prompt(c, 'c', ";o", TRUE);
+        g_print("firing!");
+        ext_proxy_eval_script(c, "testHint();", NULL);
+        /* vb_enter_prompt(c, 'c', ";o", TRUE); */
     } else {
         char prompt[2] = {info->key, '\0'};
         vb_enter_prompt(c, 'c', prompt, TRUE);
diff --git a/src/scripts/hints.js b/src/scripts/hints.js
new file mode 100644 (file)
index 0000000..7f4dcf2
--- /dev/null
@@ -0,0 +1,545 @@
+var hints = Object.freeze((function(){
+    'use strict';
+
+    var hints      = [],               /* holds all hint data (hinted element, label, number) in view port */
+        docs       = [],               /* hold the affected document with the start and end index of the hints */
+        validHints = [],               /* holds the valid hinted elements matching the filter condition */
+        activeHint,                    /* holds the active hint object */
+        filterText = "",               /* holds the typed filter text */
+        filterNum  = 0,                /* holds the numeric filter */
+        /* TODO remove these classes and use the 'vimbhint' attribute for */
+        /* styling the hints and labels - but this might break user */
+        /* stylesheets that use the classes for styling */
+        cId      = "_hintContainer",   /* id of the container holding the hint labels */
+        lClass   = "_hintLabel",       /* class used on the hint labels with the hint numbers */
+        hClass   = "_hintElem",        /* marks hinted elements */
+        fClass   = "_hintFocus",       /* marks focused element and focussed hint */
+        config;
+    /* the hint class used to maintain hinted element and labels */
+    function Hint() {
+        /* hide hint label and remove coloring from hinted element */
+        this.hide = function() {
+            /* remove hint labels from no more visible hints */
+            this.label.style.display = "none";
+            this.e.classList.remove(fClass);
+            this.e.classList.remove(hClass);
+        };
+
+        /* show the hint element colored with the hint label */
+        this.show = function() {
+            this.label.style.display = "";
+            this.e.classList.add(hClass);
+
+            /* create the label with the hint number */
+            var text = [];
+            if (this.e instanceof HTMLInputElement) {
+                var type = this.e.type;
+                if (type === "checkbox") {
+                    text.push(this.e.checked ? "☑" : "☐");
+                } else if (type === "radio") {
+                    text.push(this.e.checked ? "⊙" : "○");
+                }
+            }
+            if (this.showText && this.text) {
+                text.push(this.text.substr(0, 20));
+            }
+            /* use \x20 instead of ' ' to keep this space during js2h.sh processing */
+            this.label.innerText = this.num + (text.length ? ":\x20" + text.join("\x20") : "");
+        };
+    }
+
+    function clear() {
+        var i, j, doc, e;
+        for (i = 0; i < docs.length; i++) {
+            doc = docs[i];
+            /* find all hinted elements vimbhint 'hint' */
+            var res = xpath(doc.doc, "//*[contains(@vimbhint, 'hint')]");
+            for (j = 0; j < res.snapshotLength; j++) {
+                e = res.snapshotItem(j);
+                e.removeAttribute("vimbhint");
+                e.classList.remove(fClass);
+                e.classList.remove(hClass);
+            }
+            doc.div.parentNode.removeChild(doc.div);
+        }
+        docs       = [];
+        hints      = [];
+        validHints = [];
+        filterText = "";
+        filterNum  = 0;
+    }
+
+    function create() {
+        var count = 0;
+
+        function helper(win, offsets) {
+            /* document may be undefined for frames out of the same origin */
+            /* policy and will break the whole code - so we check this before */
+            if (typeof win.document == "undefined") {
+                return;
+            }
+
+            offsets        = offsets || {left: 0, right: 0, top: 0, bottom: 0};
+            offsets.right  = win.innerWidth  - offsets.right;
+            offsets.bottom = win.innerHeight - offsets.bottom;
+
+            /* checks if given elemente is in viewport and visible */
+            function isVisible(e) {
+                if (typeof e == "undefined") {
+                    return false;
+                }
+                var rect = e.getBoundingClientRect();
+                if (!rect ||
+                    rect.top >= offsets.bottom || rect.bottom <= offsets.top ||
+                    rect.left >= offsets.right || rect.right <= offsets.left
+                ) {
+                    return false;
+                }
+
+                if ((!rect.width || !rect.height) && (e.textContent || !e.name)) {
+                    var arr   = Array.prototype.slice.call(e.childNodes);
+                    var check = function(e) {
+                        return e instanceof Element
+                            && e.style.float != "none"
+                            && isVisible(e);
+                    };
+                    if (!arr.some(check)) {
+                        return false;
+                    }
+                }
+
+                var s = win.getComputedStyle(e, null);
+                return s.display !== "none" && s.visibility == "visible";
+            }
+
+            var doc       = win.document,
+                res       = xpath(doc, config.xpath),
+                /* generate basic hint element which will be cloned and updated later */
+                labelTmpl = doc.createElement("span"),
+                e, i;
+
+            labelTmpl.className = lClass;
+            labelTmpl.setAttribute("vimbhint", "label");
+
+            var containerOffsets = getOffsets(doc),
+                offsetX = containerOffsets[0],
+                offsetY = containerOffsets[1],
+                fragment = doc.createDocumentFragment(),
+                rect, label, text, showText, start = hints.length;
+
+            /* collect all visible elements in hints array */
+            for (i = 0; i < res.snapshotLength; i++) {
+                e = res.snapshotItem(i);
+                if (!isVisible(e)) {
+                    continue;
+                }
+
+                count++;
+
+                /* create the hint label with number */
+                rect  = e.getClientRects()[0];
+                label = labelTmpl.cloneNode(false);
+                label.setAttribute(
+                    "style", [
+                        "display:none;",
+                        "left:", Math.max((rect.left + offsetX), offsetX), "px;",
+                        "top:", Math.max((rect.top + offsetY), offsetY), "px;"
+                    ].join("")
+                );
+
+                /* if hinted element is an image - show title or alt of the image in hint label */
+                /* this allows to see how to filter for the image */
+                text = "";
+                showText = false;
+                if (e instanceof HTMLImageElement) {
+                    text     = e.title || e.alt;
+                    showText = true;
+                } else if (e.firstElementChild instanceof HTMLImageElement && /^\s*$/.test(e.textContent)) {
+                    text     = e.firstElementChild.title || e.firstElementChild.alt;
+                    showText = true;
+                } else if (e instanceof HTMLInputElement) {
+                    var type = e.type;
+                    if (type === "image") {
+                        text = e.alt || "";
+                    } else if (e.value && type !== "password") {
+                        text     = e.value;
+                        showText = (type === "radio" || type === "checkbox");
+                    }
+                } else if (e instanceof HTMLSelectElement) {
+                    if (e.selectedIndex >= 0) {
+                        text = e.item(e.selectedIndex).text;
+                    }
+                } else {
+                    text = e.textContent;
+                }
+                /* add the hint class to the hinted element */
+                fragment.appendChild(label);
+                e.setAttribute("vimbhint", "hint");
+
+                hints.push({
+                    e:         e,
+                    label:     label,
+                    text:      text,
+                    showText:  showText,
+                    __proto__: new Hint
+                });
+
+                if (count >= config.maxHints) {
+                    break;
+                }
+            }
+
+            /* append the fragment to the document */
+            var hDiv = doc.createElement("div");
+            hDiv.id  = cId;
+            hDiv.setAttribute("vimbhint", "container");
+            hDiv.appendChild(fragment);
+            if (doc.body) {
+                doc.body.appendChild(hDiv);
+            }
+            /* create the default style sheet */
+            createStyle(doc);
+
+            docs.push({
+                doc:   doc,
+                start: start,
+                end:   hints.length - 1,
+                div:   hDiv
+            });
+
+            /* recurse into any iframe or frame element */
+            for (i = 0; i < win.frames.length; i++) {
+                var rect,
+                    f = win.frames[i],
+                    e = f.frameElement;
+
+                if (isVisible(e)) {
+                    rect = e.getBoundingClientRect();
+                    helper(f, {
+                        left:   Math.max(offsets.left - rect.left, 0),
+                        right:  Math.max(rect.right   - offsets.right, 0),
+                        top:    Math.max(offsets.top  - rect.top, 0),
+                        bottom: Math.max(rect.bottom  - offsets.bottom, 0)
+                    });
+                }
+            }
+        }
+
+        helper(window);
+    }
+
+    function show(fireLast) {
+        var i, hint, newIdx,
+            n       = 1,
+            matcher = getMatcher(filterText),
+            str     = getHintString(filterNum);
+
+        if (config.hintNumSameLength) {
+            /* get number of hints to be shown */
+            var hintCount = 0;
+            for (i = 0; i < hints.length; i++) {
+                if (matcher(hints[i].text)) {
+                    hintCount++;
+                }
+            }
+            /* increase starting point of hint numbers until there are */
+            /* enough available numbers */
+            var len = config.hintKeys.length;
+            while (n * (len - 1) < hintCount) {
+                n *= len;
+            }
+        }
+
+        /* clear the array of valid hints */
+        validHints = [];
+        for (i = 0; i < hints.length; i++) {
+            hint = hints[i];
+            /* hide hints not matching the filter text */
+            if (!matcher(hint.text)) {
+                hint.hide();
+            } else {
+                /* assign the new hint number/letters as label to the hint */
+                hint.num = getHintString(n++);
+                /* check for number filter */
+                if (!filterNum || 0 === hint.num.indexOf(str)) {
+                    hint.show();
+                    validHints.push(hint);
+                } else {
+                    hint.hide();
+                }
+            }
+        }
+        if (fireLast && config.followLast && validHints.length <= 1) {
+            focusHint(0);
+            return fire();
+        }
+
+        /* if the previous active hint isn't valid set focus to first */
+        if (!activeHint || validHints.indexOf(activeHint) < 0) {
+            return focusHint(0);
+        }
+    }
+
+    /* Returns a validator method to check if the hint elements text matches */
+    /* the given filter text. */
+    function getMatcher(text) {
+        var tokens = text.toLowerCase().split(/\s+/);
+        return function (itemText) {
+            itemText = itemText.toLowerCase();
+            return tokens.every(function (token) {
+                return 0 <= itemText.indexOf(token);
+            });
+        };
+    }
+
+    /* Retrun the hint string for a given number based on configured hintkeys */
+    function getHintString(n) {
+        var res = [],
+            len = config.hintKeys.length;
+        do {
+            res.push(config.hintKeys[n % len]);
+            n = Math.floor(n / len);
+        } while (n > 0);
+
+        return res.reverse().join("");
+    }
+
+    function getOffsets(doc) {
+        var body  = doc.body || doc.documentElement,
+            style = body.style,
+            rect;
+
+        if (style && /^(absolute|fixed|relative)$/.test(style.position)) {
+            rect = body.getClientRects()[0];
+            return [-rect.left, -rect.top];
+        }
+        return [doc.defaultView.scrollX, doc.defaultView.scrollY];
+    }
+
+    function createStyle(doc) {
+        if (doc.hasStyle) {
+            return;
+        }
+        var e = doc.createElement("style");
+        /* HINT_CSS is replaces by the contents of the HINT_CSS constant from config.h */
+        e.innerHTML = "HINT_CSS";
+        doc.head.appendChild(e);
+        /* prevent us from adding the style multiple times */
+        doc.hasStyle = true;
+    }
+
+    function focus(back) {
+        var idx = validHints.indexOf(activeHint);
+        /* previous active hint not found */
+        if (idx < 0) {
+            idx = 0;
+        }
+
+        if (back) {
+            if (--idx < 0) {
+                idx = validHints.length - 1;
+            }
+        } else {
+            if (++idx >= validHints.length) {
+                idx = 0;
+            }
+        }
+        return focusHint(idx);
+    }
+
+    function fire() {
+        if (!activeHint) {
+            return "ERROR:";
+        }
+
+        var e = activeHint.e,
+            res;
+
+        /* process form actions like focus toggling inputs */
+        if (config.handleForm) {
+            res = handleForm(e);
+        }
+
+        if (config.keepOpen) {
+            /* reset the filter number */
+            filterNum = 0;
+            show(false);
+        } else {
+            clear();
+        }
+
+        return res || config.action(e);
+    }
+
+    /* focus or toggle form fields */
+    function handleForm(e) {
+        var tag  = e.nodeName.toLowerCase(),
+            type = e.type || "";
+
+        if (tag === "input" || tag === "textarea" || tag === "select") {
+            if (type === "radio" || type === "checkbox") {
+                e.focus();
+                click(e);
+                return "DONE:";
+            }
+            if (type === "submit" || type === "reset" || type  === "button" || type === "image") {
+                click(e);
+                return "DONE:";
+            }
+            e.focus();
+            return "INSERT:";
+        }
+        if (tag === "iframe" || tag === "frame") {
+            e.focus();
+            return "DONE:";
+        }
+    }
+
+    /* internal used methods */
+    function open(e, newWin) {
+        var oldTarget = e.target;
+        if (newWin) {
+            /* set target to open in new window */
+            e.target = "_blank";
+        } else if (e.target === "_blank") {
+            e.removeAttribute("target");
+        }
+        /* to open links in new window the mouse events are fired with ctrl */
+        /* key - otherwise some ugly pages will ignore this attribute in their */
+        /* mouse event observers like duckduckgo */
+        click(e, newWin);
+        e.target = oldTarget;
+    }
+
+    /* set focus on hint with given index valid hints array */
+    function focusHint(newIdx) {
+        /* reset previous focused hint */
+        if (activeHint) {
+            activeHint.e.classList.remove(fClass);
+            activeHint.label.classList.remove(fClass);
+            mouseEvent(activeHint.e, "mouseout");
+        }
+        /* get the new active hint */
+        if ((activeHint = validHints[newIdx])) {
+            activeHint.e.classList.add(fClass);
+            activeHint.label.classList.add(fClass);
+            mouseEvent(activeHint.e, "mouseover");
+
+            return "OVER:" + getSrc(activeHint.e);;
+        }
+    }
+
+    function click(e, ctrl) {
+        mouseEvent(e, "mouseover", ctrl);
+        mouseEvent(e, "mousedown", ctrl);
+        mouseEvent(e, "mouseup", ctrl);
+        mouseEvent(e, "click", ctrl);
+    }
+
+    function mouseEvent(e, name, ctrl) {
+        var evObj = e.ownerDocument.createEvent("MouseEvents");
+        evObj.initMouseEvent(
+            name, true, true, e.ownerDocument.defaultView,
+            0, 0, 0, 0, 0,
+            (typeof ctrl != "undefined") ? ctrl : false, false, false, false, 0, null
+        );
+        e.dispatchEvent(evObj);
+    }
+
+    /* retrieves the url of given element */
+    function getSrc(e) {
+        return e.href || e.src || "";
+    }
+
+    function xpath(doc, expr) {
+        return doc.evaluate(
+            expr, doc, function (p) {return "http://www.w3.org/1999/xhtml";},
+            XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null
+        );
+    }
+
+    function allFrames(win) {
+        var i, f, frames = [win];
+        for (i = 0; i < win.frames.length; i++) {
+            frames.push(win.frames[i].frameElement);
+        }
+        return frames;
+    }
+
+    /* the api */
+    return {
+        init: function init(mode, keepOpen, maxHints, hintKeys, followLast, hintNumSameLength) {
+            var prop,
+                /* holds the xpaths for the different modes */
+                xpathmap = {
+                    otY:     "//*[@href] | //*[@onclick or @tabindex or @class='lk' or @role='link' or @role='button'] | //input[not(@type='hidden' or @disabled or @readonly)] | //textarea[not(@disabled or @readonly)] | //button | //select",
+                    e:       "//input[not(@type) or @type='text'] | //textarea",
+                    iI:      "//img[@src]",
+                    OpPsTxy: "//*[@href] | //img[@src and not(ancestor::a)] | //iframe[@src]"
+                },
+                /* holds the actions to perform on hint fire */
+                actionmap = {
+                    o:          function(e) {open(e, false); return "DONE:";},
+                    t:          function(e) {open(e, true); return "DONE:";},
+                    eiIOpPsTxy: function(e) {return "DATA:" + getSrc(e);},
+                    Y:          function(e) {return "DATA:" + (e.textContent || "");}
+                };
+
+            config = {
+                maxHints:   maxHints,
+                keepOpen:   keepOpen,
+                /* handle forms only useful when there are form fields in xpath */
+                /* don't handle form for Y to allow to yank form filed content */
+                /* instead of switching to input mode */
+                handleForm:        ("eot".indexOf(mode) >= 0),
+                hintKeys:          hintKeys,
+                followLast:        followLast,
+                hintNumSameLength: hintNumSameLength,
+            };
+            for (prop in xpathmap) {
+                if (prop.indexOf(mode) >= 0) {
+                    config["xpath"] = xpathmap[prop];
+                    break;
+                }
+            }
+            for (prop in actionmap) {
+                if (prop.indexOf(mode) >= 0) {
+                    config["action"] = actionmap[prop];
+                    break;
+                }
+            }
+
+            create();
+            return show(true);
+        },
+        filter: function filter(text) {
+            /* remove previously set number filters to make the filter */
+            /* easier to understand for the users */
+            filterNum  = 0;
+            filterText = text || "";
+            return show(true);
+        },
+        update: function update(n) {
+            var pos,
+                keys = config.hintKeys;
+            /* delete last filter number digit */
+            if (null === n && filterNum) {
+                filterNum = Math.floor(filterNum / keys.length);
+                return show(false);
+            }
+            if ((pos = keys.indexOf(n)) >= 0) {
+                filterNum = filterNum * keys.length + pos;
+                return show(true);
+            }
+            return "ERROR:";
+        },
+        clear:        clear,
+        fire:         fire,
+        focus:        focus,
+    };
+})());
+
+function testHint() {
+    console.log("harmless testing!");
+    /* hints.init('t', false, 500, '', false, false); */
+}