1 // Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
2 // Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
3 // Copyright (c) 2008-2012 Kris Maglione <maglione.k@gmail.com>
5 // This work is licensed for reuse under an MIT license. Details are
6 // given in the LICENSE.txt file included with this file.
10 /** @instance hints */
12 var HintSession = Class("HintSession", CommandMode, {
13 get extendedMode() modes.HINTS,
15 init: function init(mode, opts) {
21 opts.window = modes.getStack(0).params.window;
23 this.hintMode = hints.modes[mode];
24 dactyl.assert(this.hintMode);
26 this.activeTimeout = null; // needed for hinttimeout > 0
27 this.continue = Boolean(opts.continue);
29 this.hintKeys = DOM.Event.parse(options["hintkeys"]).map(DOM.Event.closure.stringify);
31 this.hintString = opts.filter || "";
34 this.usedTabKey = false;
35 this.validHints = []; // store the indices of the "hints" array with valid elements
37 mappings.pushCommand();
40 this.top = opts.window || content;
41 this.top.addEventListener("resize", this.closure._onResize, true);
42 this.top.addEventListener("dactyl-commandupdate", this.closure._onResize, false, true);
49 if (this.validHints.length == 0) {
53 else if (this.validHints.length == 1 && !this.continue)
60 get active() this._active,
64 this.span.setAttribute("active", true);
66 this.span.removeAttribute("active");
68 hints.setClass(this.elem, this.valid ? val : null);
70 hints.setClass(this.imgSpan, this.valid ? val : null);
73 get ambiguous() this.span.hasAttribute("ambiguous"),
75 let meth = val ? "setAttribute" : "removeAttribute";
76 this.elem[meth]("ambiguous", "true");
77 this.span[meth]("ambiguous", "true");
79 this.imgSpan[meth]("ambiguous", "true");
82 get valid() this._valid,
86 this.span.style.display = (val ? "" : "none");
88 this.imgSpan.style.display = (val ? "" : "none");
89 this.active = this.active;
93 get mode() modes.HINTS,
95 get prompt() ["Question", UTF8(this.hintMode.prompt) + ": "],
97 leave: function leave(stack) {
98 leave.superapply(this, arguments);
101 mappings.popCommand();
103 if (hints.hintSession == this)
104 hints.hintSession = null;
106 this.top.removeEventListener("resize", this.closure._onResize, true);
107 this.top.removeEventListener("dactyl-commandupdate", this.closure._onResize, true);
114 checkUnique: function _checkUnique() {
115 if (this.hintNumber == 0)
117 dactyl.assert(this.hintNumber <= this.validHints.length);
119 // if we write a numeric part like 3, but we have 45 hints, only follow
120 // the hint after a timeout, as the user might have wanted to follow link 34
121 if (this.hintNumber > 0 && this.hintNumber * this.hintKeys.length <= this.validHints.length) {
122 let timeout = options["hinttimeout"];
124 this.activeTimeout = this.timeout(function () {
128 else // we have a unique hint
133 * Clear any timeout which might be active after pressing a number
135 clearTimeout: function () {
136 if (this.activeTimeout)
137 this.activeTimeout.cancel();
138 this.activeTimeout = null;
141 _escapeNumbers: false,
142 get escapeNumbers() this._escapeNumbers,
143 set escapeNumbers(val) {
145 this._escapeNumbers = !!val;
146 if (val && this.usedTabKey)
149 this.updateStatusline();
153 * Returns the hint string for a given number based on the values of
154 * the 'hintkeys' option.
156 * @param {number} n The number to transform.
159 getHintString: function getHintString(n) {
160 let res = [], len = this.hintKeys.length;
162 res.push(this.hintKeys[n % len]);
163 n = Math.floor(n / len);
166 return res.reverse().join("");
170 * The reverse of {@link #getHintString}. Given a hint string,
173 * @param {string} str The hint's string.
174 * @returns {number} The hint's index.
176 getHintNumber: function getHintNumber(str) {
177 let base = this.hintKeys.length;
179 for (let char in values(str))
180 res = res * base + this.hintKeys.indexOf(char);
185 * Returns true if the given key string represents a
186 * pseudo-hint-number.
188 * @param {string} key The key to test.
189 * @returns {boolean} Whether the key represents a hint number.
191 isHintKey: function isHintKey(key) this.hintKeys.indexOf(key) >= 0,
194 * Gets the actual offset of an imagemap area.
196 * Only called by {@link #_generate}.
198 * @param {Object} elem The <area> element.
199 * @param {number} leftPos The left offset of the image.
200 * @param {number} topPos The top offset of the image.
201 * @returns [leftPos, topPos] The updated offsets.
203 getAreaOffset: function _getAreaOffset(elem, leftPos, topPos) {
205 // Need to add the offset to the area element.
206 // Always try to find the top-left point, as per dactyl default.
207 let shape = elem.getAttribute("shape").toLowerCase();
208 let coordStr = elem.getAttribute("coords");
209 // Technically it should be only commas, but hey
210 coordStr = coordStr.replace(/\s+[;,]\s+/g, ",").replace(/\s+/g, ",");
211 let coords = coordStr.split(",").map(Number);
213 if ((shape == "rect" || shape == "rectangle") && coords.length == 4) {
214 leftPos += coords[0];
217 else if (shape == "circle" && coords.length == 3) {
218 leftPos += coords[0] - coords[2] / Math.sqrt(2);
219 topPos += coords[1] - coords[2] / Math.sqrt(2);
221 else if ((shape == "poly" || shape == "polygon") && coords.length % 2 == 0) {
222 let leftBound = Infinity;
223 let topBound = Infinity;
225 // First find the top-left corner of the bounding rectangle (offset from image topleft can be noticeably suboptimal)
226 for (let i = 0; i < coords.length; i += 2) {
227 leftBound = Math.min(coords[i], leftBound);
228 topBound = Math.min(coords[i + 1], topBound);
233 let curDist = Infinity;
235 // Then find the closest vertex. (we could generalize to nearest point on an edge, but I doubt there is a need)
236 for (let i = 0; i < coords.length; i += 2) {
237 let leftOffset = coords[i] - leftBound;
238 let topOffset = coords[i + 1] - topBound;
239 let dist = Math.sqrt(leftOffset * leftOffset + topOffset * topOffset);
240 if (dist < curDist) {
243 curTop = coords[i + 1];
247 // If we found a satisfactory offset, let's use it.
248 if (curDist < Infinity)
249 return [leftPos + curLeft, topPos + curTop];
252 catch (e) {} // badly formed document, or shape == "default" in which case we don't move the hint
253 return [leftPos, topPos];
256 // the containing block offsets with respect to the viewport
257 getContainerOffsets: function _getContainerOffsets(doc) {
258 let body = doc.body || doc.documentElement;
259 // TODO: getComputedStyle returns null for Facebook channel_iframe doc - probable Gecko bug.
260 let style = DOM(body).style;
262 if (style && /^(absolute|fixed|relative)$/.test(style.position)) {
263 let rect = body.getClientRects()[0];
264 return [-rect.left, -rect.top];
267 return [doc.defaultView.scrollX, doc.defaultView.scrollY];
271 * Generate the hints in a window.
273 * Pushes the hints into the pageHints object, but does not display them.
275 * @param {Window} win The window for which to generate hints.
278 generate: function _generate(win, offsets) {
282 let doc = win.document;
284 memoize(doc, "dactylLabels", function ()
285 iter([l.getAttribute("for"), l]
286 for (l in array.iterValues(doc.querySelectorAll("label[for]"))))
289 let [offsetX, offsetY] = this.getContainerOffsets(doc);
291 offsets = offsets || { left: 0, right: 0, top: 0, bottom: 0 };
292 offsets.right = win.innerWidth - offsets.right;
293 offsets.bottom = win.innerHeight - offsets.bottom;
295 function isVisible(elem) {
296 let rect = elem.getBoundingClientRect();
298 rect.top > offsets.bottom || rect.bottom < offsets.top ||
299 rect.left > offsets.right || rect.right < offsets.left)
302 if (!rect.width || !rect.height)
303 if (!Array.some(elem.childNodes, function (elem) elem instanceof Element && DOM(elem).style.float != "none" && isVisible(elem)))
304 if (elem.textContent || !elem.name)
307 let computedStyle = doc.defaultView.getComputedStyle(elem, null);
308 if (computedStyle.visibility != "visible" || computedStyle.display == "none")
313 let body = doc.body || doc.querySelector("body");
315 let fragment = DOM(["div", { highlight: "hints" }], doc).appendTo(body);
316 fragment.style.height; // Force application of binding.
317 let container = doc.getAnonymousElementByAttribute(fragment[0], "anonid", "hints") || fragment[0];
319 let baseNode = DOM(["span", { highlight: "Hint", style: "display: none;" }], doc)[0];
321 let mode = this.hintMode;
322 let res = mode.matcher(doc);
324 let start = this.pageHints.length;
326 for (let elem in res)
327 if (isVisible(elem) && (!mode.filter || mode.filter(elem)))
330 rect: elem.getClientRects()[0] || elem.getBoundingClientRect(),
335 for (let hint in values(_hints)) {
336 let { elem, rect } = hint;
338 if (elem.hasAttributeNS(NS, "hint"))
339 [hint.text, hint.showText] = [elem.getAttributeNS(NS, "hint"), true];
340 else if (isinstance(elem, [HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement]))
341 [hint.text, hint.showText] = hints.getInputHint(elem, doc);
342 else if (elem.firstElementChild instanceof HTMLImageElement && /^\s*$/.test(elem.textContent))
343 [hint.text, hint.showText] = [elem.firstElementChild.alt || elem.firstElementChild.title, true];
345 hint.text = elem.textContent.toLowerCase();
347 hint.span = baseNode.cloneNode(false);
349 let leftPos = Math.max((rect.left + offsetX), offsetX);
350 let topPos = Math.max((rect.top + offsetY), offsetY);
352 if (elem instanceof HTMLAreaElement)
353 [leftPos, topPos] = this.getAreaOffset(elem, leftPos, topPos);
355 hint.span.setAttribute("style", ["display: none; left:", leftPos, "px; top:", topPos, "px"].join(""));
356 container.appendChild(hint.span);
358 this.pageHints.push(hint);
361 this.docs.push({ doc: doc, start: start, end: this.pageHints.length - 1 });
364 Array.forEach(win.frames, function (f) {
365 if (isVisible(f.frameElement)) {
366 let rect = f.frameElement.getBoundingClientRect();
368 left: Math.max(offsets.left - rect.left, 0),
369 right: Math.max(rect.right - offsets.right, 0),
370 top: Math.max(offsets.top - rect.top, 0),
371 bottom: Math.max(rect.bottom - offsets.bottom, 0)
382 * Will update the filter on displayed hints and follow the final hint if
385 * @param {Event} event The keypress event.
387 onChange: function onChange(event) {
388 this.prevInput = "text";
393 this.hintString = commandline.command;
394 this.updateStatusline();
396 if (this.validHints.length == 1)
401 * Handle a hints mode event.
403 * @param {Event} event The event to handle.
405 onKeyPress: function onKeyPress(eventList) {
406 const KILL = false, PASS = true;
407 let key = DOM.Event.stringify(eventList[0]);
411 if (!this.escapeNumbers && this.isHintKey(key)) {
412 this.prevInput = "number";
414 let oldHintNumber = this.hintNumber;
415 if (this.usedTabKey) {
417 this.usedTabKey = false;
419 this.hintNumber = this.hintNumber * this.hintKeys.length +
420 this.hintKeys.indexOf(key);
422 this.updateStatusline();
424 if (this.docs.length)
425 this.updateValidNumbers();
431 this.showActiveHint(this.hintNumber, oldHintNumber || 1);
433 dactyl.assert(this.hintNumber != 0);
442 onResize: function onResize() {
444 this.generate(this.top);
448 _onResize: function _onResize() {
450 hints.resizeTimer.tell();
456 * Called when there are one or zero hints in order to possibly activate it
457 * and, if activated, to clean up the rest of the hinting system.
459 * @param {boolean} followFirst Whether to force the following of the first
460 * link (when 'followhints' is 1 or 2)
463 process: function _processHints(followFirst) {
464 dactyl.assert(this.validHints.length > 0);
466 // This "followhints" option is *too* confusing. For me, and
467 // presumably for users, too. --Kris
468 if (options["followhints"] > 0 && !followFirst)
469 return; // no return hit; don't examine uniqueness
472 let firstHref = this.validHints[0].elem.getAttribute("href") || null;
474 if (this.validHints.some(function (h) h.elem.getAttribute("href") != firstHref))
477 else if (this.validHints.length > 1)
481 let timeout = followFirst || events.feedingKeys ? 0 : 500;
482 let activeIndex = (this.hintNumber ? this.hintNumber - 1 : 0);
483 let elem = this.validHints[activeIndex].elem;
489 this.removeHints(timeout);
493 if (Cu.isDeadWrapper && Cu.isDeadWrapper(elem))
494 // Hint document has been unloaded.
497 let hinted = n || this.validHints.some(function (h) h.elem === elem);
499 hints.setClass(elem, null);
501 hints.setClass(elem, n % 2);
503 hints.setClass(elem, this.validHints[Math.max(0, this.hintNumber - 1)].elem === elem);
506 this.timeout(next, 50);
509 mappings.pushCommand();
510 if (!this.continue) {
513 modes.push(modes.IGNORE, modes.HINTS);
516 dactyl.trapErrors("action", this.hintMode,
517 elem, elem.href || elem.src || "",
518 this.extendedhintCount, top);
519 mappings.popCommand();
521 this.timeout(function () {
522 if (modes.main == modes.IGNORE && !this.continue)
524 commandline.lastEcho = null; // Hack.
525 if (this.continue && this.top)
531 * Remove all hints from the document, and reset the completions.
533 * Lingers on the active hint briefly to confirm the selection to the user.
535 * @param {number} timeout The number of milliseconds before the active
538 removeHints: function _removeHints(timeout) {
539 for (let { doc, start, end } in values(this.docs)) {
540 DOM(doc.documentElement).highlight.remove("Hinting");
541 // Goddamn stupid fucking Gecko 1.x security manager bullshit.
542 try { delete doc.dactylLabels; } catch (e) { doc.dactylLabels = undefined; }
544 for (let elem in DOM.XPath("//*[@dactyl:highlight='hints']", doc))
545 elem.parentNode.removeChild(elem);
546 for (let i in util.range(start, end + 1)) {
547 this.pageHints[i].ambiguous = false;
548 this.pageHints[i].valid = false;
551 styles.system.remove("hint-positions");
556 reset: function reset() {
558 this.validHints = [];
562 _reset: function _reset() {
563 if (!this.usedTabKey)
565 if (this.continue && this.validHints.length <= 1) {
566 this.hintString = "";
567 commandline.widgets.command = this.hintString;
570 this.updateStatusline();
574 * Display the hints in pageHints that are still valid.
577 show: function _show() {
578 let count = ++this.showCount;
580 let validHint = hints.hintMatcher(this.hintString.toLowerCase());
581 let activeHint = this.hintNumber || 1;
582 this.validHints = [];
584 for (let { doc, start, end } in values(this.docs)) {
585 DOM(doc.documentElement).highlight.add("Hinting");
586 let [offsetX, offsetY] = this.getContainerOffsets(doc);
589 for (let i in (util.interruptibleRange(start, end + 1, 500))) {
590 if (this.showCount != count)
593 let hint = this.pageHints[i];
595 hint.valid = validHint(hint.text);
599 if (hint.text == "" && hint.elem.firstChild && hint.elem.firstChild instanceof HTMLImageElement) {
601 let rect = hint.elem.firstChild.getBoundingClientRect();
605 hint.imgSpan = DOM(["span", { highlight: "Hint", "dactyl:hl": "HintImage" }], doc).css({
607 left: (rect.left + offsetX) + "px",
608 top: (rect.top + offsetY) + "px",
609 width: (rect.right - rect.left) + "px",
610 height: (rect.bottom - rect.top) + "px"
611 }).appendTo(hint.span.parentNode)[0];
615 let str = this.getHintString(hintnum);
617 if (hint.elem instanceof HTMLInputElement)
618 if (hint.elem.type === "radio")
619 text.push(UTF8(hint.elem.checked ? "⊙" : "○"));
620 else if (hint.elem.type === "checkbox")
621 text.push(UTF8(hint.elem.checked ? "☑" : "☐"));
622 if (hint.showText && !/^\s*$/.test(hint.text))
623 text.push(hint.text.substr(0, 50));
625 hint.span.setAttribute("text", str + (text.length ? ": " + text.join(" ") : ""));
626 hint.span.setAttribute("number", str);
628 hint.imgSpan.setAttribute("number", str);
630 hint.active = activeHint == hintnum;
632 this.validHints.push(hint);
637 let base = this.hintKeys.length;
638 for (let [i, hint] in Iterator(this.validHints))
639 hint.ambiguous = (i + 1) * base <= this.validHints.length;
641 if (options["usermode"]) {
643 for (let hint in values(this.pageHints)) {
644 let selector = highlight.selector("Hint") + "[number=" + hint.span.getAttribute("number").quote() + "]";
645 let imgSpan = "[dactyl|hl=HintImage]";
646 css.push(selector + ":not(" + imgSpan + ") { " + hint.span.style.cssText + " }");
648 css.push(selector + imgSpan + " { " + hint.span.style.cssText + " }");
650 styles.system.add("hint-positions", "*", css.join("\n"));
657 * Update the activeHint.
659 * By default highlights it green instead of yellow.
661 * @param {number} newId The hint to make active.
662 * @param {number} oldId The currently active hint.
664 showActiveHint: function _showActiveHint(newId, oldId) {
665 let oldHint = this.validHints[oldId - 1];
667 oldHint.active = false;
669 let newHint = this.validHints[newId - 1];
671 newHint.active = true;
674 backspace: function () {
676 if (this.prevInput !== "number")
679 if (this.hintNumber > 0 && !this.usedTabKey) {
680 this.hintNumber = Math.floor(this.hintNumber / this.hintKeys.length);
681 if (this.hintNumber == 0)
682 this.prevInput = "text";
686 this.usedTabKey = false;
693 updateValidNumbers: function updateValidNumbers(always) {
694 let string = this.getHintString(this.hintNumber);
695 for (let hint in values(this.validHints))
696 hint.valid = always || hint.span.getAttribute("number").indexOf(string) == 0;
699 tab: function tab(previous) {
701 this.usedTabKey = true;
702 if (this.hintNumber == 0)
705 let oldId = this.hintNumber;
707 if (++this.hintNumber > this.validHints.length)
711 if (--this.hintNumber < 1)
712 this.hintNumber = this.validHints.length;
715 this.updateValidNumbers(true);
716 this.showActiveHint(this.hintNumber, oldId);
717 this.updateStatusline();
720 update: function update(followFirst) {
722 this.updateStatusline();
724 if (this.docs.length == 0 && this.hintString.length > 0)
728 this.process(followFirst);
732 * Display the current status to the user.
734 updateStatusline: function _updateStatusline() {
735 statusline.inputBuffer = (this.escapeNumbers ? "\\" : "") +
736 (this.hintNumber ? this.getHintString(this.hintNumber) : "");
740 var Hints = Module("hints", {
741 init: function init() {
742 this.resizeTimer = Timer(100, 500, function () {
743 if (isinstance(modes.main, modes.HINTS))
744 modes.getStack(0).params.onResize();
747 let appContent = document.getElementById("appcontent");
749 events.listen(appContent, "scroll", this.resizeTimer.closure.tell, false);
751 const Mode = Hints.Mode;
752 Mode.prototype.__defineGetter__("matcher", function ()
753 options.get("extendedhinttags").getKey(this.name, options.get("hinttags").matcher));
756 this.addMode(";", "Focus hint", buffer.closure.focusElement);
757 this.addMode("?", "Show information for hint", function (elem) buffer.showElementInfo(elem));
758 // TODO: allow for ! override to overwrite existing paths -- where? --djk
759 this.addMode("s", "Save hint", function (elem) buffer.saveLink(elem, false));
760 this.addMode("f", "Focus frame", function (elem) dactyl.focus(elem.ownerDocument.defaultView));
761 this.addMode("F", "Focus frame or pseudo-frame", buffer.closure.focusElement, isScrollable);
762 this.addMode("o", "Follow hint", function (elem) buffer.followLink(elem, dactyl.CURRENT_TAB));
763 this.addMode("t", "Follow hint in a new tab", function (elem) buffer.followLink(elem, dactyl.NEW_TAB));
764 this.addMode("b", "Follow hint in a background tab", function (elem) buffer.followLink(elem, dactyl.NEW_BACKGROUND_TAB));
765 this.addMode("w", "Follow hint in a new window", function (elem) buffer.followLink(elem, dactyl.NEW_WINDOW));
766 this.addMode("O", "Generate an ‘:open URL’ prompt", function (elem, loc) CommandExMode().open("open " + loc));
767 this.addMode("T", "Generate a ‘:tabopen URL’ prompt", function (elem, loc) CommandExMode().open("tabopen " + loc));
768 this.addMode("W", "Generate a ‘:winopen URL’ prompt", function (elem, loc) CommandExMode().open("winopen " + loc));
769 this.addMode("a", "Add a bookmark", function (elem) bookmarks.addSearchKeyword(elem));
770 this.addMode("S", "Add a search keyword", function (elem) bookmarks.addSearchKeyword(elem));
771 this.addMode("v", "View hint source", function (elem, loc) buffer.viewSource(loc, false));
772 this.addMode("V", "View hint source in external editor", function (elem, loc) buffer.viewSource(loc, true));
773 this.addMode("y", "Yank hint location", function (elem, loc) editor.setRegister(null, loc, true));
774 this.addMode("Y", "Yank hint description", function (elem) editor.setRegister(null, elem.textContent || "", true));
775 this.addMode("A", "Yank hint anchor url", function (elem) {
776 let uri = elem.ownerDocument.documentURIObject.clone();
777 uri.ref = elem.id || elem.name;
778 dactyl.clipboardWrite(uri.spec, true);
780 this.addMode("c", "Open context menu", function (elem) DOM(elem).contextmenu());
781 this.addMode("i", "Show image", function (elem) dactyl.open(elem.src));
782 this.addMode("I", "Show image in a new tab", function (elem) dactyl.open(elem.src, dactyl.NEW_TAB));
784 function isScrollable(elem) isinstance(elem, [HTMLFrameElement, HTMLIFrameElement]) ||
785 Buffer.isScrollable(elem, 0, true) || Buffer.isScrollable(elem, 0, false);
788 hintSession: Modes.boundProperty(),
791 * Creates a new hints mode.
793 * @param {string} mode The letter that identifies this mode.
794 * @param {string} prompt The description to display to the user
796 * @param {function(Node)} action The function to be called with the
797 * element that matches.
798 * @param {function(Node):boolean} filter A function used to filter
799 * the returned node set.
800 * @param {[string]} tags A value to add to the default
801 * 'extendedhinttags' value for this mode.
804 addMode: function (mode, prompt, action, filter, tags) {
805 function toString(regexp) RegExp.prototype.toString.call(regexp);
808 let eht = options.get("extendedhinttags");
809 let update = eht.isDefault;
811 let value = eht.parse(Option.quote(util.regexp.escape(mode)) + ":" + tags.map(Option.quote))[0];
812 eht.defaultValue = eht.defaultValue.filter(function (re) toString(re) != toString(value))
819 this.modes[mode] = Hints.Mode(mode, UTF8(prompt), action, filter);
823 * Get a hint for "input", "textarea" and "select".
825 * Tries to use <label>s if possible but does not try to guess that a
826 * neighboring element might look like a label. Only called by
827 * {@link #_generate}.
829 * If it finds a hint it returns it, if the hint is not the caption of the
830 * element it will return showText=true.
832 * @param {Object} elem The element used to generate hint text.
833 * @param {Document} doc The containing document.
835 * @returns [text, showText]
837 getInputHint: function _getInputHint(elem, doc) {
838 // <input type="submit|button|reset"/> Always use the value
839 // <input type="radio|checkbox"/> Use the value if it is not numeric or label or name
840 // <input type="password"/> Never use the value, use label or name
841 // <input type="text|file"/> <textarea/> Use value if set or label or name
842 // <input type="image"/> Use the alt text if present (showText) or label or name
843 // <input type="hidden"/> Never gets here
844 // <select/> Use the text of the selected item or label or name
846 let type = elem.type;
848 if (DOM(elem).isInput)
849 return [elem.value, false];
851 for (let [, option] in Iterator(options["hintinputs"])) {
852 if (option == "value") {
853 if (elem instanceof HTMLSelectElement) {
854 if (elem.selectedIndex >= 0)
855 return [elem.item(elem.selectedIndex).text.toLowerCase(), false];
857 else if (type == "image") {
859 return [elem.alt.toLowerCase(), true];
861 else if (elem.value && type != "password") {
862 // radios and checkboxes often use internal ids as values - maybe make this an option too...
863 if (! ((type == "radio" || type == "checkbox") && !isNaN(elem.value)))
864 return [elem.value.toLowerCase(), (type == "radio" || type == "checkbox")];
867 else if (option == "label") {
869 let label = (elem.ownerDocument.dactylLabels || {})[elem.id];
872 return [label.textContent.toLowerCase(), true];
875 else if (option == "name")
876 return [elem.name.toLowerCase(), true];
884 * Get the hintMatcher according to user preference.
886 * @param {string} hintString The currently typed hint.
887 * @returns {hintMatcher}
889 hintMatcher: function _hintMatcher(hintString) { //{{{
891 * Divide a string by a regular expression.
893 * @param {RegExp|string} pat The pattern to split on.
894 * @param {string} str The string to split.
895 * @returns {Array(string)} The lowercased splits of the splitting.
897 function tokenize(pat, str) str.split(pat).map(String.toLowerCase);
900 * Get a hint matcher for hintmatching=contains
902 * The hintMatcher expects the user input to be space delimited and it
903 * returns true if each set of characters typed can be found, in any
904 * order, in the link.
906 * @param {string} hintString The string typed by the user.
907 * @returns {function(String):boolean} A function that takes the text
908 * of a hint and returns true if all the (space-delimited) sets of
909 * characters typed by the user can be found in it.
911 function containsMatcher(hintString) { //{{{
912 let tokens = tokenize(/\s+/, hintString);
913 return function (linkText) {
914 linkText = linkText.toLowerCase();
915 return tokens.every(function (token) indexOf(linkText, token) >= 0);
920 * Get a hintMatcher for hintmatching=firstletters|wordstartswith
922 * The hintMatcher will look for any division of the user input that
923 * would match the first letters of words. It will always only match
926 * @param {string} hintString The string typed by the user.
927 * @param {boolean} allowWordOverleaping Whether to allow non-contiguous
929 * @returns {function(String):boolean} A function that will filter only
930 * hints that match as above.
932 function wordStartsWithMatcher(hintString, allowWordOverleaping) { //{{{
933 let hintStrings = tokenize(/\s+/, hintString);
934 let wordSplitRegexp = util.regexp(options["wordseparators"]);
937 * Match a set of characters to the start of words.
939 * What the **** does this do? --Kris
940 * This function matches hintStrings like 'hekho' to links
941 * like 'Hey Kris, how are you?' -> [HE]y [K]ris [HO]w are you
944 * @param {string} chars The characters to match.
945 * @param {Array(string)} words The words to match them against.
946 * @param {boolean} allowWordOverleaping Whether words may be
947 * skipped during matching.
948 * @returns {boolean} Whether a match can be found.
950 function charsAtBeginningOfWords(chars, words, allowWordOverleaping) {
951 function charMatches(charIdx, chars, wordIdx, words, inWordIdx, allowWordOverleaping) {
952 let matches = (chars[charIdx] == words[wordIdx][inWordIdx]);
953 if ((matches == false && allowWordOverleaping) || words[wordIdx].length == 0) {
954 let nextWordIdx = wordIdx + 1;
955 if (nextWordIdx == words.length)
958 return charMatches(charIdx, chars, nextWordIdx, words, 0, allowWordOverleaping);
962 let nextCharIdx = charIdx + 1;
963 if (nextCharIdx == chars.length)
966 let nextWordIdx = wordIdx + 1;
967 let beyondLastWord = (nextWordIdx == words.length);
968 let charMatched = false;
969 if (beyondLastWord == false)
970 charMatched = charMatches(nextCharIdx, chars, nextWordIdx, words, 0, allowWordOverleaping);
975 if (charMatched == false || beyondLastWord == true) {
976 let nextInWordIdx = inWordIdx + 1;
977 if (nextInWordIdx == words[wordIdx].length)
980 return charMatches(nextCharIdx, chars, wordIdx, words, nextInWordIdx, allowWordOverleaping);
987 return charMatches(0, chars, 0, words, 0, allowWordOverleaping);
991 * Check whether the array of strings all exist at the start of the
994 * i.e. ['ro', 'e'] would match ['rollover', 'effect']
996 * The matches must be in order, and, if allowWordOverleaping is
999 * @param {Array(string)} strings The strings to search for.
1000 * @param {Array(string)} words The words to search in.
1001 * @param {boolean} allowWordOverleaping Whether matches may be
1003 * @returns {boolean} Whether all the strings matched.
1005 function stringsAtBeginningOfWords(strings, words, allowWordOverleaping) {
1007 for (let [, word] in Iterator(words)) {
1008 if (word.length == 0)
1011 let str = strings[strIdx];
1012 if (str.length == 0 || indexOf(word, str) == 0)
1014 else if (!allowWordOverleaping)
1017 if (strIdx == strings.length)
1021 for (; strIdx < strings.length; strIdx++) {
1022 if (strings[strIdx].length != 0)
1028 return function (linkText) {
1029 if (hintStrings.length == 1 && hintStrings[0].length == 0)
1032 let words = tokenize(wordSplitRegexp, linkText);
1033 if (hintStrings.length == 1)
1034 return charsAtBeginningOfWords(hintStrings[0], words, allowWordOverleaping);
1036 return stringsAtBeginningOfWords(hintStrings, words, allowWordOverleaping);
1040 let indexOf = String.indexOf;
1041 if (options.get("hintmatching").has("transliterated"))
1042 indexOf = Hints.closure.indexOf;
1044 switch (options["hintmatching"][0]) {
1045 case "contains" : return containsMatcher(hintString);
1046 case "wordstartswith": return wordStartsWithMatcher(hintString, true);
1047 case "firstletters" : return wordStartsWithMatcher(hintString, false);
1048 case "custom" : return dactyl.plugins.customHintMatcher(hintString);
1049 default : dactyl.echoerr(_("hints.noMatcher", hintMatching));
1054 open: function open(mode, opts) {
1055 this._extendedhintCount = opts.count;
1059 mappings.pushCommand();
1060 commandline.input(["Normal", mode], null, {
1061 autocomplete: false,
1062 completer: function (context) {
1063 context.compare = function () 0;
1064 context.completions = [[k, v.prompt] for ([k, v] in Iterator(hints.modes))];
1066 onCancel: mappings.closure.popCommand,
1067 onSubmit: function (arg) {
1069 hints.show(arg, opts);
1070 mappings.popCommand();
1072 onChange: function (arg) {
1073 if (Object.keys(hints.modes).some(function (m) m != arg && m.indexOf(arg) == 0))
1076 this.accepted = true;
1083 * Toggle the highlight of a hint.
1085 * @param {Object} elem The element to toggle.
1086 * @param {boolean} active Whether it is the currently active hint or not.
1088 setClass: function _setClass(elem, active) {
1089 if (elem.dactylHighlight == null)
1090 elem.dactylHighlight = elem.getAttributeNS(NS, "highlight") || "";
1092 let prefix = (elem.getAttributeNS(NS, "hl") || "") + " " + elem.dactylHighlight + " ";
1094 highlight.highlightNode(elem, prefix + "HintActive");
1095 else if (active != null)
1096 highlight.highlightNode(elem, prefix + "HintElem");
1098 highlight.highlightNode(elem, elem.dactylHighlight);
1099 // delete elem.dactylHighlight fails on Gecko 1.9. Issue #197
1100 elem.dactylHighlight = null;
1104 show: function show(mode, opts) {
1105 this.hintSession = HintSession(mode, opts);
1108 isVisible: function isVisible(elem, offScreen) {
1109 let rect = elem.getBoundingClientRect();
1110 if (!rect.width || !rect.height)
1111 if (!Array.some(elem.childNodes, function (elem) elem instanceof Element && DOM(elem).style.float != "none" && isVisible(elem)))
1114 let win = elem.ownerDocument.defaultView;
1115 if (offScreen && (rect.top + win.scrollY < 0 || rect.left + win.scrollX < 0 ||
1116 rect.bottom + win.scrollY > win.scrolMaxY + win.innerHeight ||
1117 rect.right + win.scrollX > win.scrolMaxX + win.innerWidth))
1120 if (!DOM(elem).isVisible)
1125 translitTable: Class.Memoize(function () {
1128 [0x00c0, 0x00c6, ["A"]], [0x00c7, 0x00c7, ["C"]],
1129 [0x00c8, 0x00cb, ["E"]], [0x00cc, 0x00cf, ["I"]],
1130 [0x00d1, 0x00d1, ["N"]], [0x00d2, 0x00d6, ["O"]],
1131 [0x00d8, 0x00d8, ["O"]], [0x00d9, 0x00dc, ["U"]],
1132 [0x00dd, 0x00dd, ["Y"]], [0x00e0, 0x00e6, ["a"]],
1133 [0x00e7, 0x00e7, ["c"]], [0x00e8, 0x00eb, ["e"]],
1134 [0x00ec, 0x00ef, ["i"]], [0x00f1, 0x00f1, ["n"]],
1135 [0x00f2, 0x00f6, ["o"]], [0x00f8, 0x00f8, ["o"]],
1136 [0x00f9, 0x00fc, ["u"]], [0x00fd, 0x00fd, ["y"]],
1137 [0x00ff, 0x00ff, ["y"]], [0x0100, 0x0105, ["A", "a"]],
1138 [0x0106, 0x010d, ["C", "c"]], [0x010e, 0x0111, ["D", "d"]],
1139 [0x0112, 0x011b, ["E", "e"]], [0x011c, 0x0123, ["G", "g"]],
1140 [0x0124, 0x0127, ["H", "h"]], [0x0128, 0x0130, ["I", "i"]],
1141 [0x0132, 0x0133, ["IJ", "ij"]], [0x0134, 0x0135, ["J", "j"]],
1142 [0x0136, 0x0136, ["K", "k"]], [0x0139, 0x0142, ["L", "l"]],
1143 [0x0143, 0x0148, ["N", "n"]], [0x0149, 0x0149, ["n"]],
1144 [0x014c, 0x0151, ["O", "o"]], [0x0152, 0x0153, ["OE", "oe"]],
1145 [0x0154, 0x0159, ["R", "r"]], [0x015a, 0x0161, ["S", "s"]],
1146 [0x0162, 0x0167, ["T", "t"]], [0x0168, 0x0173, ["U", "u"]],
1147 [0x0174, 0x0175, ["W", "w"]], [0x0176, 0x0178, ["Y", "y", "Y"]],
1148 [0x0179, 0x017e, ["Z", "z"]], [0x0180, 0x0183, ["b", "B", "B", "b"]],
1149 [0x0187, 0x0188, ["C", "c"]], [0x0189, 0x0189, ["D"]],
1150 [0x018a, 0x0192, ["D", "D", "d", "F", "f"]],
1151 [0x0193, 0x0194, ["G"]],
1152 [0x0197, 0x019b, ["I", "K", "k", "l", "l"]],
1153 [0x019d, 0x01a1, ["N", "n", "O", "O", "o"]],
1154 [0x01a4, 0x01a5, ["P", "p"]], [0x01ab, 0x01ab, ["t"]],
1155 [0x01ac, 0x01b0, ["T", "t", "T", "U", "u"]],
1156 [0x01b2, 0x01d2, ["V", "Y", "y", "Z", "z", "D", "L", "N", "A", "a",
1157 "I", "i", "O", "o"]],
1158 [0x01d3, 0x01dc, ["U", "u"]], [0x01de, 0x01e1, ["A", "a"]],
1159 [0x01e2, 0x01e3, ["AE", "ae"]],
1160 [0x01e4, 0x01ed, ["G", "g", "G", "g", "K", "k", "O", "o", "O", "o"]],
1161 [0x01f0, 0x01f5, ["j", "D", "G", "g"]],
1162 [0x01fa, 0x01fb, ["A", "a"]], [0x01fc, 0x01fd, ["AE", "ae"]],
1163 [0x01fe, 0x0217, ["O", "o", "A", "a", "A", "a", "E", "e", "E", "e",
1164 "I", "i", "I", "i", "O", "o", "O", "o", "R", "r", "R", "r", "U",
1166 [0x0253, 0x0257, ["b", "c", "d", "d"]],
1167 [0x0260, 0x0269, ["g", "h", "h", "i", "i"]],
1168 [0x026b, 0x0273, ["l", "l", "l", "l", "m", "n", "n"]],
1169 [0x027c, 0x028b, ["r", "r", "r", "r", "s", "t", "u", "u", "v"]],
1170 [0x0290, 0x0291, ["z"]], [0x029d, 0x02a0, ["j", "q"]],
1171 [0x1e00, 0x1e09, ["A", "a", "B", "b", "B", "b", "B", "b", "C", "c"]],
1172 [0x1e0a, 0x1e13, ["D", "d"]], [0x1e14, 0x1e1d, ["E", "e"]],
1173 [0x1e1e, 0x1e21, ["F", "f", "G", "g"]], [0x1e22, 0x1e2b, ["H", "h"]],
1174 [0x1e2c, 0x1e8f, ["I", "i", "I", "i", "K", "k", "K", "k", "K", "k",
1175 "L", "l", "L", "l", "L", "l", "L", "l", "M", "m", "M", "m", "M",
1176 "m", "N", "n", "N", "n", "N", "n", "N", "n", "O", "o", "O", "o",
1177 "O", "o", "O", "o", "P", "p", "P", "p", "R", "r", "R", "r", "R",
1178 "r", "R", "r", "S", "s", "S", "s", "S", "s", "S", "s", "S", "s",
1179 "T", "t", "T", "t", "T", "t", "T", "t", "U", "u", "U", "u", "U",
1180 "u", "U", "u", "U", "u", "V", "v", "V", "v", "W", "w", "W", "w",
1181 "W", "w", "W", "w", "W", "w", "X", "x", "X", "x", "Y", "y"]],
1182 [0x1e90, 0x1e9a, ["Z", "z", "Z", "z", "Z", "z", "h", "t", "w", "y", "a"]],
1183 [0x1ea0, 0x1eb7, ["A", "a"]], [0x1eb8, 0x1ec7, ["E", "e"]],
1184 [0x1ec8, 0x1ecb, ["I", "i"]], [0x1ecc, 0x1ee3, ["O", "o"]],
1185 [0x1ee4, 0x1ef1, ["U", "u"]], [0x1ef2, 0x1ef9, ["Y", "y"]],
1186 [0x2071, 0x2071, ["i"]], [0x207f, 0x207f, ["n"]],
1187 [0x249c, 0x24b5, "a"], [0x24b6, 0x24cf, "A"],
1188 [0x24d0, 0x24e9, "a"],
1189 [0xfb00, 0xfb06, ["ff", "fi", "fl", "ffi", "ffl", "st", "st"]],
1190 [0xff21, 0xff3a, "A"], [0xff41, 0xff5a, "a"]
1191 ].forEach(function ([start, stop, val]) {
1192 if (typeof val != "string")
1193 for (let i = start; i <= stop; i++)
1194 table[String.fromCharCode(i)] = val[(i - start) % val.length];
1196 let n = val.charCodeAt(0);
1197 for (let i = start; i <= stop; i++)
1198 table[String.fromCharCode(i)] = String.fromCharCode(n + i - start);
1203 indexOf: function indexOf(dest, src) {
1204 let table = this.translitTable;
1205 var end = dest.length - src.length;
1206 if (src.length == 0)
1209 for (var i = 0; i <= end; i++) {
1211 for (var k = 0; k < src.length;) {
1214 for (var l = 0; l < s.length; l++, k++) {
1217 if (k == src.length - 1)
1225 Mode: Struct("HintMode", "name", "prompt", "action", "filter")
1228 modes: function initModes() {
1229 initModes.require("commandline");
1230 modes.addMode("HINTS", {
1232 description: "Active when selecting elements with hints",
1233 bases: [modes.COMMAND_LINE],
1238 mappings: function () {
1239 let bind = function bind(names, description, action, params)
1240 mappings.add(config.browserModes, names, description,
1245 function () { hints.show("o"); });
1248 "Start Hints mode, but open link in a new tab",
1249 function () { hints.show(options.get("activate").has("links") ? "t" : "b"); });
1252 "Start an extended hints mode",
1253 function ({ count }) { hints.open(";", { count: count }); },
1257 "Start an extended hints mode and stay there until <Esc> is pressed",
1258 function ({ count }) { hints.open("g;", { continue: true, count: count }); },
1261 let bind = function bind(names, description, action, params)
1262 mappings.add([modes.HINTS], names, description,
1266 "Follow the selected hint",
1267 function ({ self }) { self.update(true); });
1270 "Focus the next matching hint",
1271 function ({ self }) { self.tab(false); });
1274 "Focus the previous matching hint",
1275 function ({ self }) { self.tab(true); });
1277 bind(["<BS>", "<C-h>"],
1278 "Delete the previous character",
1279 function ({ self }) self.backspace());
1282 "Toggle hint filtering",
1283 function ({ self }) { self.escapeNumbers = !self.escapeNumbers; });
1285 options: function () {
1286 options.add(["extendedhinttags", "eht"],
1287 "XPath or CSS selector strings of hintable elements for extended hint modes",
1289 // Make sure to update the docs when you change this.
1291 "[asOTvVWy]": [":-moz-any-link", "area[href]", "img[src]", "iframe[src]"],
1292 "[A]": ["[id]", "a[name]"],
1294 "[F]": ["body", "code", "div", "html", "p", "pre", "span"],
1295 "[S]": ["input:not([type=hidden])", "textarea", "button", "select"]
1299 getKey: function (val, default_)
1300 let (res = array.nth(this.value, function (re) let (match = re.exec(val)) match && match[0] == val, 0))
1301 res ? res.matcher : default_,
1302 parse: function parse(val) {
1303 let vals = parse.supercall(this, val);
1304 for (let value in values(vals))
1305 value.matcher = DOM.compileMatcher(Option.splitList(value.result));
1308 testValues: function testValues(vals, validator) vals.every(function (re) Option.splitList(re).every(validator)),
1309 validator: DOM.validateMatcher
1312 options.add(["hinttags", "ht"],
1313 "XPath or CSS selector strings of hintable elements for Hints mode",
1314 // Make sure to update the docs when you change this.
1315 "stringlist", ":-moz-any-link,area,button,iframe,input:not([type=hidden]),label[for],select,textarea," +
1316 "[onclick],[onmouseover],[onmousedown],[onmouseup],[oncommand]," +
1317 "[tabindex],[role=link],[role=button],[contenteditable=true]",
1319 setter: function (values) {
1320 this.matcher = DOM.compileMatcher(values);
1323 validator: DOM.validateMatcher
1326 options.add(["hintkeys", "hk"],
1327 "The keys used to label and select hints",
1328 "string", "0123456789",
1331 "0123456789": "Numbers",
1332 "asdfg;lkjh": "Home Row"
1334 validator: function (value) {
1335 let values = DOM.Event.parse(value).map(DOM.Event.closure.stringify);
1336 return Option.validIf(array.uniq(values).length === values.length && values.length > 1,
1337 _("option.hintkeys.duplicate"));
1341 options.add(["hinttimeout", "hto"],
1342 "Timeout before automatically following a non-unique numerical hint",
1344 { validator: function (value) value >= 0 });
1346 options.add(["followhints", "fh"],
1347 "Define the conditions under which selected hints are followed",
1351 "0": "Follow the first hint as soon as typed text uniquely identifies it. Follow the selected hint on <Return>.",
1352 "1": "Follow the selected hint on <Return>.",
1356 options.add(["hintmatching", "hm"],
1357 "How hints are filtered",
1358 "stringlist", "contains",
1361 "contains": "The typed characters are split on whitespace. The resulting groups must all appear in the hint.",
1362 "custom": "Delegate to a custom function: dactyl.plugins.customHintMatcher(hintString)",
1363 "firstletters": "Behaves like wordstartswith, but all groups must match a sequence of words.",
1364 "wordstartswith": "The typed characters are split on whitespace. The resulting groups must all match the beginnings of words, in order.",
1365 "transliterated": UTF8("When true, special latin characters are translated to their ASCII equivalents (e.g., é ⇒ e)")
1367 validator: function (values) Option.validateCompleter.call(this, values) &&
1368 1 === values.reduce(function (acc, v) acc + (["contains", "custom", "firstletters", "wordstartswith"].indexOf(v) >= 0), 0)
1371 options.add(["wordseparators", "wsp"],
1372 "Regular expression defining which characters separate words when matching hints",
1373 "string", '[.,!?:;/"^$%&?()[\\]{}<>#*+|=~ _-]',
1374 { validator: function (value) RegExp(value) });
1376 options.add(["hintinputs", "hin"],
1377 "Which text is used to filter hints for input elements",
1378 "stringlist", "label,value",
1381 "value": "Match against the value of the input field",
1382 "label": "Match against the text of a label for the input field, if one can be found",
1383 "name": "Match against the name of the input field"
1389 // vim: set fdm=marker sw=4 ts=4 et: