aboutsummaryrefslogtreecommitdiffhomepage
path: root/examples/data/scripts/follower.js
blob: dc37b46462406862dfef554827dc9038950d43aa (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
// A Link Follower for Uzbl.
// P.C. Shyamshankar <sykora@lucentbeing.com>
//
// WARNING: this script depends on the Uzbl object which is now disabled for
// WARNING  security reasons. So the script currently doesn't work but it's
// WARNING  interesting nonetheless
//
// Based extensively (like copy-paste) on the follow_numbers.js and
// linkfollow.js included with uzbl, but modified to be more customizable and
// extensible.
//
// Usage
// -----
//
// First, you'll need to make sure the script is loaded on each page. This can
// be done with:
//
// @on_event LOAD_COMMIT script /path/to/follower.js
//
// Then you can bind it to a key:
//
// @bind f* = js follower.follow('%s', matchSpec, handler, hintStyler)
//
// where matchSpec, handler and hintStyler are parameters which control the
// operation of follower. If you don't want to customize any further, you can
// set these to follower.genericMatchSpec, follower.genericHandler and
// follower.genericHintStyler respectively.
//
// For example,
//
// @bind f* = js follower.follow('%s', follower.genericMatchSpec, follower.genericHandler, follower.genericHintStyler)
// @bind F* = js follower.follow('%s', follower.onlyLinksMatchSpec, follower.newPageHandler, follower.newPageHintStyler)
//
// In order to make hints disappear when pressing a key (the Escape key, for
// example), you can do this:
//
// @bind <Escape> = js follower.clearHints()
//
// If your Escape is already bound to something like command mode, chain it.
//
// Alternatively, you can tell your <Escape> key to emit an event, and handle
// that instead.
//
// @bind <Escape> = event ESCAPE
// @on_event ESCAPE js follower.clearHints()
//
// Customization
// -------------
//
// If however you do want to customize, 3 Aspects of the link follower can be
// customized with minimal pain or alteration to the existing code base:
//
//  * What elements are hinted.
//  * The style of the hints displayed.
//  * How the hints are handled.
//
// In order to customize behavior, write an alternative, and pass that in to
// follower.follow invocation. You _will_ have to modify this script, but only
// locally, it beats having to copy the entire script under a new name and
// modify.
//
// TODO:
//  * Whatever all the other TODOs in the file say.
//  * Find out how to do default arguments in Javascript.
//  * Abstract out the hints into a Hint object, make hintables a list of hint
//    objects instead of two lists.

// Helpers
String.prototype.lpad = function(padding, length) {
    var padded = this;
    while (padded.length < length) {
        padded = padding + padded;
    }

    return padded;
}

function Follower() {

    // Globals
    var uzblID = 'uzbl-follow'; // ID to apply to each hint.
    var uzblContainerID = 'uzbl-follow-container'; // ID to apply to the div containing hints.

    // Translation table, used to display something other than numbers as hint
    // labels. Typically set to the ten keys of the home row.
    //
    // Must have exactly 10 elements.
    //
    // I haven't parameterized this, to make it customizable. Should I? Do
    // people really use more than one set of keys at a time?
    var translation = ["a", "r", "s", "t", "d", "h", "n", "e", "i", "o"];

    // MatchSpecs
    // These are XPath expressions which indicate which elements will be hinted.
    // Use multiple expressions for different situations, like hinting only form
    // elements, or only links, etc.
    //
    // TODO: Check that these XPath expressions are correct, and optimize/make
    // them more elegant. Preferably by someone who actually knows XPath, unlike
    // me.

    // Vimperator default (copy-pasted, I never used vimperator).
    this.genericMatchSpec = " //*[@onclick or @onmouseover or @onmousedown or @onmouseup or @oncommand or @class='lk' or @role='link' or @href] | //input[not(@type='hidden')] | //a | //area | //iframe | //textarea | //button | //select";

    // Matches only links, suitable for opening in a new instance (I think).
    this.onlyLinksMatchSpec = " //*[@href] | //a | //area";

    // Follow Handlers
    // These decide how an element should be 'followed'. The handler is passed
    // the element in question.

    // Generic Handler, opens links in the same instance, emits the FORM_ACTIVE
    // event if a form element was chosen. Also clears the keycmd.
    this.genericHandler = function(node) {
        if (node) {
            if (window.itemClicker != undefined) {
                window.itemClicker(node);
            } else {
                var tag = node.tagName.toLowerCase();
                if (tag == 'a') {
                    node.click();
                    window.location = node.href;
                } else if (tag == 'input') {
                    var inputType = node.getAttribute('type');
                    if (inputType == undefined)
                        inputType = 'text';

                    inputType = inputType.toLowerCase();

                    if (inputType == 'text' || inputType == 'file' || inputType == 'password') {
                        node.focus();
                        node.select();
                    } else {
                        node.click();
                    }
                    Uzbl.run("event FORM_ACTIVE");
                } else if (tag == 'textarea'|| tag == 'select') {
                    node.focus();
                    node.select();
                    Uzbl.run("event FORM_ACTIVE");
                } else {
                    node.click();
                    if ((node.href != undefined) && node.href)
                        window.location = node.href;
                }
            }
        }
        Uzbl.run("event SET_KEYCMD");
    }

    // Handler to open links in a new page. The rest is the same as before.
    this.newPageHandler = function(node) {
        if (node) {
            if (window.itemClicker != undefined) {
                window.itemClicker(node);
            } else {
                var tag = node.tagName.toLowerCase();
                if (tag == 'a') {
                    node.click();
                    Uzbl.run("@new_window " + node.href);
                } else if (tag == 'input') {
                    var inputType = node.getAttribute('type');
                    if (inputType == undefined)
                        inputType = 'text';

                    inputType = inputType.toLowerCase();

                    if (inputType == 'text' || inputType == 'file' || inputType == 'password') {
                        node.focus();
                        node.select();
                    } else {
                        node.click();
                    }
                    Uzbl.run("event FORM_ACTIVE");
                } else if (tag == 'textarea'|| tag == 'select') {
                    node.focus();
                    node.select();
                    Uzbl.run("event FORM_ACTIVE");
                } else {
                    node.click();
                    if ((node.href != undefined) && node.href)
                        window.location = node.href;
                }
            }
        }
        Uzbl.run("event SET_KEYCMD");
    };

    // Hint styling.
    // Pretty much any attribute of the hint object can be modified here, but it
    // was meant to change the styling. Useful to differentiate between hints
    // with different handlers.
    //
    // Hint stylers are applied at the end of hint creation, so that they
    // override the defaults.

    this.genericHintStyler = function(hint) {
        hint.style.backgroundColor = '#AAAAAA';
        hint.style.border = '2px solid #4A6600';
        hint.style.color = 'black';
        hint.style.fontSize = '10px';
        hint.style.fontWeight = 'bold';
        hint.style.lineHeight = '12px';
        return hint;
    };

    this.newPageHintStyler = function(hint) {
        hint.style.backgroundColor = '#FFCC00';
        hint.style.border = '2px solid #4A6600';
        hint.style.color = 'black';
        hint.style.fontSize = '10px';
        hint.style.fontWeight = 'bold';
        hint.style.lineHeight = '12px';
        return hint;
    };

    // Beyond lies a jungle of pasta and verbosity.

    // Translate a numeric label using the translation table.
    function translate(digitLabel, translationTable) {
        translatedLabel = '';
        for (var i = 0; i < digitLabel.length; i++) {
            translatedLabel += translationTable[digitLabel.charAt(i)];
        }

        return translatedLabel;
    }

    function computeElementPosition(element) {
        var up = element.offsetTop;
        var left = element.offsetLeft;
        var width = element.offsetWidth;
        var height = element.offsetHeight;

        while (element.offsetParent) {
            element = element.offsetParent;
            up += element.offsetTop;
            left += element.offsetLeft;
        }

        return {up: up, left: left, width: width, height: height};
    }

    // Pretty much copy-pasted from every other link following script.
    function isInViewport(element) {
        offset = computeElementPosition(element);

        var up = offset.up;
        var left = offset.left;
        var width = offset.width;
        var height = offset.height;

        return up < window.pageYOffset + window.innerHeight &&
               left < window.pageXOffset + window.innerWidth &&
               (up + height) > window.pageYOffset &&
               (left + width) > window.pageXOffset;
    }

    function isVisible(element) {
        if (element == document) {
            return true;
        }

        if (!element){
            return false;
        }

        if (element.style) {
            if (element.style.display == 'none' || element.style.visibiilty == 'hidden') {
                return false;
            }
        }

        return isVisible(element.parentNode);
    }

    function generateHintContainer() {
        var container = document.getElementById(uzblContainerID);
        if (container) {
            container.parentNode.removeChild(container);
        }

        container = document.createElement('div');
        container.id = uzblContainerID;

        if (document.body) {
            document.body.appendChild(container);
        }
        return container;
    }

    // Generate everything that is to be hinted, as per the given matchSpec.
    // hintables[0] refers to the items, hintables[1] to their labels.
    function generateHintables(matchSpec) {
        var hintables = [[], []];

        var itemsFromXPath = document.evaluate(matchSpec, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);

        for (var i = 0; i < itemsFromXPath.snapshotLength; ++i) {
            var element = itemsFromXPath.snapshotItem(i);
            if (element && isVisible(element) && isInViewport(element)) {
                hintables[0].push(element);
            }
        }

        // Assign labels to each hintable. Can't be combined with the previous
        // step, because we didn't know how many there were at that time.
        var hintLength = hintables.length;
        for (var i = 0; i < hintables[0].length; ++i) {
            var code = translate(i.toString(), translation);
            hintables[1].push(code.lpad(translation[0], hintLength));
        }

        return hintables;
    }

    // Filter the hintables based on input from the user. Makes the screen less
    // cluttered after the user has typed some prefix of hint labels.
    function filterHintables(hintables, target) {
        var filtered = [[], []];

        var targetPattern = new RegExp("^" + target);

        for (var i = 0; i < hintables[0].length; i++) {
            if (hintables[1][i].match(targetPattern)) {
                filtered[0].push(hintables[0][i]);
                filtered[1].push(hintables[1][i].substring(target.length));
            }
        }

        return filtered;
    }

    // TODO make this use the container variable from main, instead of searching
    // for it?
    function clearHints() {
        var container = document.getElementById(uzblContainerID);
        if (container) {
            container.parentNode.removeChild(container);
        }
    }

    // So that we can offer this as a separate function.
    this.clearHints = clearHints;

    function makeHint(node, code, styler) {
        var position = computeElementPosition(node);
        var hint = document.createElement('div');

        hint.name = uzblID;
        hint.innerText = code;
        hint.style.display = 'inline';

        hint.style.margin = '0px';
        hint.style.padding = '1px';
        hint.style.position = 'absolute';
        hint.style.zIndex = '10000';

        hint.style.left = position.left + 'px';
        hint.style.top = position.up + 'px';

        var img = node.getElementsByTagName('img');
        if (img.length > 0) {
            hint.style.left = position.left + img[0].width / 2 + 'px';
        }

        hint.style.textDecoration = 'none';
        hint.style.webkitBorderRadius = '6px';
        hint.style.webkitTransform = 'scale(1) rotate(0deg) translate(-6px, -5px)';

        hint = styler(hint); // So that custom hint stylers can override the above.
        return hint;
    }


    function drawHints(container, hintables, styler) {
        for (var i = 0; i < hintables[0].length; i++) {
            hint = makeHint(hintables[0][i], hintables[1][i], styler);
            container.appendChild(hint);
        }

        if (document.body) {
            document.body.appendChild(container);
        }
    }

    // The main hinting function. I don't know how to do default values to
    // functions, so all arguments must be specified. Use generics if you must.
    this.follow = function(target, matchSpec, handler, hintStyler) {
        var container = generateHintContainer(); // Get a container to hold all hints.
        var allHintables = generateHintables(matchSpec); // Get all items that can be hinted.
        hintables = filterHintables(allHintables, target); // Filter them based on current input.

        clearHints(); // Clear existing hints, if any.

        if (hintables[0].length == 0) {
            // Nothing was hinted, user pressed an unknown key, maybe?
            // Do nothing.
        } else if (hintables[0].length == 1) {
            handler(hintables[0][0]); // Only one hint remains, handle it.
        } else {
            drawHints(container, hintables, hintStyler); // Draw whatever hints remain.
        }

        return;
    };
}

// Make on-click links clickable.
try {
    HTMLElement.prototype.click = function() {
        if (typeof this.onclick == 'function') {
            this.onclick({
                type: 'click'
            });
        }
    };
} catch(e) {}

follower = new Follower();