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-2011 by 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)))
306 let computedStyle = doc.defaultView.getComputedStyle(elem, null);
307 if (computedStyle.visibility != "visible" || computedStyle.display == "none")
312 let body = doc.body || doc.querySelector("body");
314 let fragment = DOM(<div highlight="hints"/>, doc).appendTo(body);
315 fragment.style.height; // Force application of binding.
316 let container = doc.getAnonymousElementByAttribute(fragment[0], "anonid", "hints") || fragment[0];
318 let baseNode = DOM(<span highlight="Hint" style="display: none;"/>, doc)[0];
320 let mode = this.hintMode;
321 let res = mode.matcher(doc);
323 let start = this.pageHints.length;
325 for (let elem in res)
326 if (isVisible(elem) && (!mode.filter || mode.filter(elem)))
329 rect: elem.getClientRects()[0] || elem.getBoundingClientRect(),
334 for (let hint in values(_hints)) {
335 let { elem, rect } = hint;
337 if (elem.hasAttributeNS(NS, "hint"))
338 [hint.text, hint.showText] = [elem.getAttributeNS(NS, "hint"), true];
339 else if (isinstance(elem, [HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement]))
340 [hint.text, hint.showText] = hints.getInputHint(elem, doc);
341 else if (elem.firstElementChild instanceof HTMLImageElement && /^\s*$/.test(elem.textContent))
342 [hint.text, hint.showText] = [elem.firstElementChild.alt || elem.firstElementChild.title, true];
344 hint.text = elem.textContent.toLowerCase();
346 hint.span = baseNode.cloneNode(false);
348 let leftPos = Math.max((rect.left + offsetX), offsetX);
349 let topPos = Math.max((rect.top + offsetY), offsetY);
351 if (elem instanceof HTMLAreaElement)
352 [leftPos, topPos] = this.getAreaOffset(elem, leftPos, topPos);
354 hint.span.setAttribute("style", ["display: none; left:", leftPos, "px; top:", topPos, "px"].join(""));
355 container.appendChild(hint.span);
357 this.pageHints.push(hint);
360 this.docs.push({ doc: doc, start: start, end: this.pageHints.length - 1 });
363 Array.forEach(win.frames, function (f) {
364 if (isVisible(f.frameElement)) {
365 let rect = f.frameElement.getBoundingClientRect();
367 left: Math.max(offsets.left - rect.left, 0),
368 right: Math.max(rect.right - offsets.right, 0),
369 top: Math.max(offsets.top - rect.top, 0),
370 bottom: Math.max(rect.bottom - offsets.bottom, 0)
381 * Will update the filter on displayed hints and follow the final hint if
384 * @param {Event} event The keypress event.
386 onChange: function onChange(event) {
387 this.prevInput = "text";
392 this.hintString = commandline.command;
393 this.updateStatusline();
395 if (this.validHints.length == 1)
400 * Handle a hints mode event.
402 * @param {Event} event The event to handle.
404 onKeyPress: function onKeyPress(eventList) {
405 const KILL = false, PASS = true;
406 let key = DOM.Event.stringify(eventList[0]);
410 if (!this.escapeNumbers && this.isHintKey(key)) {
411 this.prevInput = "number";
413 let oldHintNumber = this.hintNumber;
414 if (this.usedTabKey) {
416 this.usedTabKey = false;
418 this.hintNumber = this.hintNumber * this.hintKeys.length +
419 this.hintKeys.indexOf(key);
421 this.updateStatusline();
423 if (this.docs.length)
424 this.updateValidNumbers();
430 this.showActiveHint(this.hintNumber, oldHintNumber || 1);
432 dactyl.assert(this.hintNumber != 0);
441 onResize: function onResize() {
443 this.generate(this.top);
447 _onResize: function _onResize() {
449 hints.resizeTimer.tell();
455 * Called when there are one or zero hints in order to possibly activate it
456 * and, if activated, to clean up the rest of the hinting system.
458 * @param {boolean} followFirst Whether to force the following of the first
459 * link (when 'followhints' is 1 or 2)
462 process: function _processHints(followFirst) {
463 dactyl.assert(this.validHints.length > 0);
465 // This "followhints" option is *too* confusing. For me, and
466 // presumably for users, too. --Kris
467 if (options["followhints"] > 0 && !followFirst)
468 return; // no return hit; don't examine uniqueness
471 let firstHref = this.validHints[0].elem.getAttribute("href") || null;
473 if (this.validHints.some(function (h) h.elem.getAttribute("href") != firstHref))
476 else if (this.validHints.length > 1)
480 let timeout = followFirst || events.feedingKeys ? 0 : 500;
481 let activeIndex = (this.hintNumber ? this.hintNumber - 1 : 0);
482 let elem = this.validHints[activeIndex].elem;
488 this.removeHints(timeout);
492 let hinted = n || this.validHints.some(function (h) h.elem === elem);
494 hints.setClass(elem, null);
496 hints.setClass(elem, n % 2);
498 hints.setClass(elem, this.validHints[Math.max(0, this.hintNumber - 1)].elem === elem);
501 this.timeout(next, 50);
504 mappings.pushCommand();
505 if (!this.continue) {
508 modes.push(modes.IGNORE, modes.HINTS);
511 dactyl.trapErrors("action", this.hintMode,
512 elem, elem.href || elem.src || "",
513 this.extendedhintCount, top);
514 mappings.popCommand();
516 this.timeout(function () {
517 if (modes.main == modes.IGNORE && !this.continue)
519 commandline.lastEcho = null; // Hack.
520 if (this.continue && this.top)
526 * Remove all hints from the document, and reset the completions.
528 * Lingers on the active hint briefly to confirm the selection to the user.
530 * @param {number} timeout The number of milliseconds before the active
533 removeHints: function _removeHints(timeout) {
534 for (let { doc, start, end } in values(this.docs)) {
535 // Goddamn stupid fucking Gecko 1.x security manager bullshit.
536 try { delete doc.dactylLabels; } catch (e) { doc.dactylLabels = undefined; }
538 for (let elem in DOM.XPath("//*[@dactyl:highlight='hints']", doc))
539 elem.parentNode.removeChild(elem);
540 for (let i in util.range(start, end + 1)) {
541 this.pageHints[i].ambiguous = false;
542 this.pageHints[i].valid = false;
545 styles.system.remove("hint-positions");
550 reset: function reset() {
552 this.validHints = [];
556 _reset: function _reset() {
557 if (!this.usedTabKey)
559 if (this.continue && this.validHints.length <= 1) {
560 this.hintString = "";
561 commandline.widgets.command = this.hintString;
564 this.updateStatusline();
568 * Display the hints in pageHints that are still valid.
570 show: function _show() {
572 let validHint = hints.hintMatcher(this.hintString.toLowerCase());
573 let activeHint = this.hintNumber || 1;
574 this.validHints = [];
576 for (let { doc, start, end } in values(this.docs)) {
577 let [offsetX, offsetY] = this.getContainerOffsets(doc);
580 for (let i in (util.interruptibleRange(start, end + 1, 500))) {
581 let hint = this.pageHints[i];
583 hint.valid = validHint(hint.text);
587 if (hint.text == "" && hint.elem.firstChild && hint.elem.firstChild instanceof HTMLImageElement) {
589 let rect = hint.elem.firstChild.getBoundingClientRect();
593 hint.imgSpan = util.xmlToDom(<span highlight="Hint" dactyl:hl="HintImage" xmlns:dactyl={NS}/>, doc);
594 hint.imgSpan.style.display = "none";
595 hint.imgSpan.style.left = (rect.left + offsetX) + "px";
596 hint.imgSpan.style.top = (rect.top + offsetY) + "px";
597 hint.imgSpan.style.width = (rect.right - rect.left) + "px";
598 hint.imgSpan.style.height = (rect.bottom - rect.top) + "px";
599 hint.span.parentNode.appendChild(hint.imgSpan);
603 let str = this.getHintString(hintnum);
605 if (hint.elem instanceof HTMLInputElement)
606 if (hint.elem.type === "radio")
607 text.push(UTF8(hint.elem.checked ? "⊙" : "○"));
608 else if (hint.elem.type === "checkbox")
609 text.push(UTF8(hint.elem.checked ? "☑" : "☐"));
610 if (hint.showText && !/^\s*$/.test(hint.text))
611 text.push(hint.text.substr(0, 50));
613 hint.span.setAttribute("text", str + (text.length ? ": " + text.join(" ") : ""));
614 hint.span.setAttribute("number", str);
616 hint.imgSpan.setAttribute("number", str);
618 hint.active = activeHint == hintnum;
620 this.validHints.push(hint);
625 let base = this.hintKeys.length;
626 for (let [i, hint] in Iterator(this.validHints))
627 hint.ambiguous = (i + 1) * base <= this.validHints.length;
629 if (options["usermode"]) {
631 for (let hint in values(this.pageHints)) {
632 let selector = highlight.selector("Hint") + "[number=" + hint.span.getAttribute("number").quote() + "]";
633 let imgSpan = "[dactyl|hl=HintImage]";
634 css.push(selector + ":not(" + imgSpan + ") { " + hint.span.style.cssText + " }");
636 css.push(selector + imgSpan + " { " + hint.span.style.cssText + " }");
638 styles.system.add("hint-positions", "*", css.join("\n"));
645 * Update the activeHint.
647 * By default highlights it green instead of yellow.
649 * @param {number} newId The hint to make active.
650 * @param {number} oldId The currently active hint.
652 showActiveHint: function _showActiveHint(newId, oldId) {
653 let oldHint = this.validHints[oldId - 1];
655 oldHint.active = false;
657 let newHint = this.validHints[newId - 1];
659 newHint.active = true;
662 backspace: function () {
664 if (this.prevInput !== "number")
667 if (this.hintNumber > 0 && !this.usedTabKey) {
668 this.hintNumber = Math.floor(this.hintNumber / this.hintKeys.length);
669 if (this.hintNumber == 0)
670 this.prevInput = "text";
674 this.usedTabKey = false;
681 updateValidNumbers: function updateValidNumbers(always) {
682 let string = this.getHintString(this.hintNumber);
683 for (let hint in values(this.validHints))
684 hint.valid = always || hint.span.getAttribute("number").indexOf(string) == 0;
687 tab: function tab(previous) {
689 this.usedTabKey = true;
690 if (this.hintNumber == 0)
693 let oldId = this.hintNumber;
695 if (++this.hintNumber > this.validHints.length)
699 if (--this.hintNumber < 1)
700 this.hintNumber = this.validHints.length;
703 this.updateValidNumbers(true);
704 this.showActiveHint(this.hintNumber, oldId);
705 this.updateStatusline();
708 update: function update(followFirst) {
710 this.updateStatusline();
712 if (this.docs.length == 0 && this.hintString.length > 0)
716 this.process(followFirst);
720 * Display the current status to the user.
722 updateStatusline: function _updateStatusline() {
723 statusline.inputBuffer = (this.escapeNumbers ? options["mapleader"] : "") +
724 (this.hintNumber ? this.getHintString(this.hintNumber) : "");
728 var Hints = Module("hints", {
729 init: function init() {
730 this.resizeTimer = Timer(100, 500, function () {
731 if (isinstance(modes.main, modes.HINTS))
732 modes.getStack(0).params.onResize();
735 let appContent = document.getElementById("appcontent");
737 events.listen(appContent, "scroll", this.resizeTimer.closure.tell, false);
739 const Mode = Hints.Mode;
740 Mode.prototype.__defineGetter__("matcher", function ()
741 options.get("extendedhinttags").getKey(this.name, options.get("hinttags").matcher));
744 this.addMode(";", "Focus hint", buffer.closure.focusElement);
745 this.addMode("?", "Show information for hint", function (elem) buffer.showElementInfo(elem));
746 this.addMode("s", "Save hint", function (elem) buffer.saveLink(elem, false));
747 this.addMode("f", "Focus frame", function (elem) dactyl.focus(elem.ownerDocument.defaultView));
748 this.addMode("F", "Focus frame or pseudo-frame", buffer.closure.focusElement, isScrollable);
749 this.addMode("o", "Follow hint", function (elem) buffer.followLink(elem, dactyl.CURRENT_TAB));
750 this.addMode("t", "Follow hint in a new tab", function (elem) buffer.followLink(elem, dactyl.NEW_TAB));
751 this.addMode("b", "Follow hint in a background tab", function (elem) buffer.followLink(elem, dactyl.NEW_BACKGROUND_TAB));
752 this.addMode("w", "Follow hint in a new window", function (elem) buffer.followLink(elem, dactyl.NEW_WINDOW));
753 this.addMode("O", "Generate an ‘:open URL’ prompt", function (elem, loc) CommandExMode().open("open " + loc));
754 this.addMode("T", "Generate a ‘:tabopen URL’ prompt", function (elem, loc) CommandExMode().open("tabopen " + loc));
755 this.addMode("W", "Generate a ‘:winopen URL’ prompt", function (elem, loc) CommandExMode().open("winopen " + loc));
756 this.addMode("a", "Add a bookmark", function (elem) bookmarks.addSearchKeyword(elem));
757 this.addMode("S", "Add a search keyword", function (elem) bookmarks.addSearchKeyword(elem));
758 this.addMode("v", "View hint source", function (elem, loc) buffer.viewSource(loc, false));
759 this.addMode("V", "View hint source in external editor", function (elem, loc) buffer.viewSource(loc, true));
760 this.addMode("y", "Yank hint location", function (elem, loc) editor.setRegister(null, loc, true));
761 this.addMode("Y", "Yank hint description", function (elem) editor.setRegister(null, elem.textContent || "", true));
762 this.addMode("c", "Open context menu", function (elem) DOM(elem).contextmenu());
763 this.addMode("i", "Show image", function (elem) dactyl.open(elem.src));
764 this.addMode("I", "Show image in a new tab", function (elem) dactyl.open(elem.src, dactyl.NEW_TAB));
766 function isScrollable(elem) isinstance(elem, [HTMLFrameElement, HTMLIFrameElement]) ||
767 Buffer.isScrollable(elem, 0, true) || Buffer.isScrollable(elem, 0, false);
770 hintSession: Modes.boundProperty(),
773 * Creates a new hints mode.
775 * @param {string} mode The letter that identifies this mode.
776 * @param {string} prompt The description to display to the user
778 * @param {function(Node)} action The function to be called with the
779 * element that matches.
780 * @param {function(Node):boolean} filter A function used to filter
781 * the returned node set.
782 * @param {[string]} tags A value to add to the default
783 * 'extendedhinttags' value for this mode.
786 addMode: function (mode, prompt, action, filter, tags) {
787 function toString(regexp) RegExp.prototype.toString.call(regexp);
790 let eht = options.get("extendedhinttags");
791 let update = eht.isDefault;
793 let value = eht.parse(Option.quote(util.regexp.escape(mode)) + ":" + tags.map(Option.quote))[0];
794 eht.defaultValue = eht.defaultValue.filter(function (re) toString(re) != toString(value))
801 this.modes[mode] = Hints.Mode(mode, UTF8(prompt), action, filter);
805 * Get a hint for "input", "textarea" and "select".
807 * Tries to use <label>s if possible but does not try to guess that a
808 * neighboring element might look like a label. Only called by
809 * {@link #_generate}.
811 * If it finds a hint it returns it, if the hint is not the caption of the
812 * element it will return showText=true.
814 * @param {Object} elem The element used to generate hint text.
815 * @param {Document} doc The containing document.
817 * @returns [text, showText]
819 getInputHint: function _getInputHint(elem, doc) {
820 // <input type="submit|button|reset"/> Always use the value
821 // <input type="radio|checkbox"/> Use the value if it is not numeric or label or name
822 // <input type="password"/> Never use the value, use label or name
823 // <input type="text|file"/> <textarea/> Use value if set or label or name
824 // <input type="image"/> Use the alt text if present (showText) or label or name
825 // <input type="hidden"/> Never gets here
826 // <select/> Use the text of the selected item or label or name
828 let type = elem.type;
830 if (DOM(elem).isInput)
831 return [elem.value, false];
833 for (let [, option] in Iterator(options["hintinputs"])) {
834 if (option == "value") {
835 if (elem instanceof HTMLSelectElement) {
836 if (elem.selectedIndex >= 0)
837 return [elem.item(elem.selectedIndex).text.toLowerCase(), false];
839 else if (type == "image") {
841 return [elem.alt.toLowerCase(), true];
843 else if (elem.value && type != "password") {
844 // radios and checkboxes often use internal ids as values - maybe make this an option too...
845 if (! ((type == "radio" || type == "checkbox") && !isNaN(elem.value)))
846 return [elem.value.toLowerCase(), (type == "radio" || type == "checkbox")];
849 else if (option == "label") {
851 let label = elem.ownerDocument.dactylLabels[elem.id];
853 return [label.textContent.toLowerCase(), true];
856 else if (option == "name")
857 return [elem.name.toLowerCase(), true];
865 * Get the hintMatcher according to user preference.
867 * @param {string} hintString The currently typed hint.
868 * @returns {hintMatcher}
870 hintMatcher: function _hintMatcher(hintString) { //{{{
872 * Divide a string by a regular expression.
874 * @param {RegExp|string} pat The pattern to split on.
875 * @param {string} str The string to split.
876 * @returns {Array(string)} The lowercased splits of the splitting.
878 function tokenize(pat, str) str.split(pat).map(String.toLowerCase);
881 * Get a hint matcher for hintmatching=contains
883 * The hintMatcher expects the user input to be space delimited and it
884 * returns true if each set of characters typed can be found, in any
885 * order, in the link.
887 * @param {string} hintString The string typed by the user.
888 * @returns {function(String):boolean} A function that takes the text
889 * of a hint and returns true if all the (space-delimited) sets of
890 * characters typed by the user can be found in it.
892 function containsMatcher(hintString) { //{{{
893 let tokens = tokenize(/\s+/, hintString);
894 return function (linkText) {
895 linkText = linkText.toLowerCase();
896 return tokens.every(function (token) indexOf(linkText, token) >= 0);
901 * Get a hintMatcher for hintmatching=firstletters|wordstartswith
903 * The hintMatcher will look for any division of the user input that
904 * would match the first letters of words. It will always only match
907 * @param {string} hintString The string typed by the user.
908 * @param {boolean} allowWordOverleaping Whether to allow non-contiguous
910 * @returns {function(String):boolean} A function that will filter only
911 * hints that match as above.
913 function wordStartsWithMatcher(hintString, allowWordOverleaping) { //{{{
914 let hintStrings = tokenize(/\s+/, hintString);
915 let wordSplitRegexp = util.regexp(options["wordseparators"]);
918 * Match a set of characters to the start of words.
920 * What the **** does this do? --Kris
921 * This function matches hintStrings like 'hekho' to links
922 * like 'Hey Kris, how are you?' -> [HE]y [K]ris [HO]w are you
925 * @param {string} chars The characters to match.
926 * @param {Array(string)} words The words to match them against.
927 * @param {boolean} allowWordOverleaping Whether words may be
928 * skipped during matching.
929 * @returns {boolean} Whether a match can be found.
931 function charsAtBeginningOfWords(chars, words, allowWordOverleaping) {
932 function charMatches(charIdx, chars, wordIdx, words, inWordIdx, allowWordOverleaping) {
933 let matches = (chars[charIdx] == words[wordIdx][inWordIdx]);
934 if ((matches == false && allowWordOverleaping) || words[wordIdx].length == 0) {
935 let nextWordIdx = wordIdx + 1;
936 if (nextWordIdx == words.length)
939 return charMatches(charIdx, chars, nextWordIdx, words, 0, allowWordOverleaping);
943 let nextCharIdx = charIdx + 1;
944 if (nextCharIdx == chars.length)
947 let nextWordIdx = wordIdx + 1;
948 let beyondLastWord = (nextWordIdx == words.length);
949 let charMatched = false;
950 if (beyondLastWord == false)
951 charMatched = charMatches(nextCharIdx, chars, nextWordIdx, words, 0, allowWordOverleaping);
956 if (charMatched == false || beyondLastWord == true) {
957 let nextInWordIdx = inWordIdx + 1;
958 if (nextInWordIdx == words[wordIdx].length)
961 return charMatches(nextCharIdx, chars, wordIdx, words, nextInWordIdx, allowWordOverleaping);
968 return charMatches(0, chars, 0, words, 0, allowWordOverleaping);
972 * Check whether the array of strings all exist at the start of the
975 * i.e. ['ro', 'e'] would match ['rollover', 'effect']
977 * The matches must be in order, and, if allowWordOverleaping is
980 * @param {Array(string)} strings The strings to search for.
981 * @param {Array(string)} words The words to search in.
982 * @param {boolean} allowWordOverleaping Whether matches may be
984 * @returns {boolean} Whether all the strings matched.
986 function stringsAtBeginningOfWords(strings, words, allowWordOverleaping) {
988 for (let [, word] in Iterator(words)) {
989 if (word.length == 0)
992 let str = strings[strIdx];
993 if (str.length == 0 || indexOf(word, str) == 0)
995 else if (!allowWordOverleaping)
998 if (strIdx == strings.length)
1002 for (; strIdx < strings.length; strIdx++) {
1003 if (strings[strIdx].length != 0)
1009 return function (linkText) {
1010 if (hintStrings.length == 1 && hintStrings[0].length == 0)
1013 let words = tokenize(wordSplitRegexp, linkText);
1014 if (hintStrings.length == 1)
1015 return charsAtBeginningOfWords(hintStrings[0], words, allowWordOverleaping);
1017 return stringsAtBeginningOfWords(hintStrings, words, allowWordOverleaping);
1021 let indexOf = String.indexOf;
1022 if (options.get("hintmatching").has("transliterated"))
1023 indexOf = Hints.indexOf;
1025 switch (options["hintmatching"][0]) {
1026 case "contains" : return containsMatcher(hintString);
1027 case "wordstartswith": return wordStartsWithMatcher(hintString, true);
1028 case "firstletters" : return wordStartsWithMatcher(hintString, false);
1029 case "custom" : return dactyl.plugins.customHintMatcher(hintString);
1030 default : dactyl.echoerr(_("hints.noMatcher", hintMatching));
1035 open: function open(mode, opts) {
1036 this._extendedhintCount = opts.count;
1040 mappings.pushCommand();
1041 commandline.input(["Normal", mode], null, {
1042 autocomplete: false,
1043 completer: function (context) {
1044 context.compare = function () 0;
1045 context.completions = [[k, v.prompt] for ([k, v] in Iterator(hints.modes))];
1047 onCancel: mappings.closure.popCommand,
1048 onSubmit: function (arg) {
1050 hints.show(arg, opts);
1051 mappings.popCommand();
1053 onChange: function (arg) {
1054 if (Object.keys(hints.modes).some(function (m) m != arg && m.indexOf(arg) == 0))
1057 this.accepted = true;
1064 * Toggle the highlight of a hint.
1066 * @param {Object} elem The element to toggle.
1067 * @param {boolean} active Whether it is the currently active hint or not.
1069 setClass: function _setClass(elem, active) {
1070 if (elem.dactylHighlight == null)
1071 elem.dactylHighlight = elem.getAttributeNS(NS, "highlight") || "";
1073 let prefix = (elem.getAttributeNS(NS, "hl") || "") + " " + elem.dactylHighlight + " ";
1075 highlight.highlightNode(elem, prefix + "HintActive");
1076 else if (active != null)
1077 highlight.highlightNode(elem, prefix + "HintElem");
1079 highlight.highlightNode(elem, elem.dactylHighlight);
1080 // delete elem.dactylHighlight fails on Gecko 1.9. Issue #197
1081 elem.dactylHighlight = null;
1085 show: function show(mode, opts) {
1086 this.hintSession = HintSession(mode, opts);
1089 isVisible: function isVisible(elem, offScreen) {
1090 let rect = elem.getBoundingClientRect();
1091 if (!rect.width || !rect.height)
1092 if (!Array.some(elem.childNodes, function (elem) elem instanceof Element && DOM(elem).style.float != "none" && isVisible(elem)))
1095 let win = elem.ownerDocument.defaultView;
1096 if (offScreen && (rect.top + win.scrollY < 0 || rect.left + win.scrollX < 0 ||
1097 rect.bottom + win.scrollY > win.scrolMaxY + win.innerHeight ||
1098 rect.right + win.scrollX > win.scrolMaxX + win.innerWidth))
1101 if (!DOM(elem).isVisible)
1106 translitTable: Class.Memoize(function () {
1109 [0x00c0, 0x00c6, ["A"]], [0x00c7, 0x00c7, ["C"]],
1110 [0x00c8, 0x00cb, ["E"]], [0x00cc, 0x00cf, ["I"]],
1111 [0x00d1, 0x00d1, ["N"]], [0x00d2, 0x00d6, ["O"]],
1112 [0x00d8, 0x00d8, ["O"]], [0x00d9, 0x00dc, ["U"]],
1113 [0x00dd, 0x00dd, ["Y"]], [0x00e0, 0x00e6, ["a"]],
1114 [0x00e7, 0x00e7, ["c"]], [0x00e8, 0x00eb, ["e"]],
1115 [0x00ec, 0x00ef, ["i"]], [0x00f1, 0x00f1, ["n"]],
1116 [0x00f2, 0x00f6, ["o"]], [0x00f8, 0x00f8, ["o"]],
1117 [0x00f9, 0x00fc, ["u"]], [0x00fd, 0x00fd, ["y"]],
1118 [0x00ff, 0x00ff, ["y"]], [0x0100, 0x0105, ["A", "a"]],
1119 [0x0106, 0x010d, ["C", "c"]], [0x010e, 0x0111, ["D", "d"]],
1120 [0x0112, 0x011b, ["E", "e"]], [0x011c, 0x0123, ["G", "g"]],
1121 [0x0124, 0x0127, ["H", "h"]], [0x0128, 0x0130, ["I", "i"]],
1122 [0x0132, 0x0133, ["IJ", "ij"]], [0x0134, 0x0135, ["J", "j"]],
1123 [0x0136, 0x0136, ["K", "k"]], [0x0139, 0x0142, ["L", "l"]],
1124 [0x0143, 0x0148, ["N", "n"]], [0x0149, 0x0149, ["n"]],
1125 [0x014c, 0x0151, ["O", "o"]], [0x0152, 0x0153, ["OE", "oe"]],
1126 [0x0154, 0x0159, ["R", "r"]], [0x015a, 0x0161, ["S", "s"]],
1127 [0x0162, 0x0167, ["T", "t"]], [0x0168, 0x0173, ["U", "u"]],
1128 [0x0174, 0x0175, ["W", "w"]], [0x0176, 0x0178, ["Y", "y", "Y"]],
1129 [0x0179, 0x017e, ["Z", "z"]], [0x0180, 0x0183, ["b", "B", "B", "b"]],
1130 [0x0187, 0x0188, ["C", "c"]], [0x0189, 0x0189, ["D"]],
1131 [0x018a, 0x0192, ["D", "D", "d", "F", "f"]],
1132 [0x0193, 0x0194, ["G"]],
1133 [0x0197, 0x019b, ["I", "K", "k", "l", "l"]],
1134 [0x019d, 0x01a1, ["N", "n", "O", "O", "o"]],
1135 [0x01a4, 0x01a5, ["P", "p"]], [0x01ab, 0x01ab, ["t"]],
1136 [0x01ac, 0x01b0, ["T", "t", "T", "U", "u"]],
1137 [0x01b2, 0x01d2, ["V", "Y", "y", "Z", "z", "D", "L", "N", "A", "a",
1138 "I", "i", "O", "o"]],
1139 [0x01d3, 0x01dc, ["U", "u"]], [0x01de, 0x01e1, ["A", "a"]],
1140 [0x01e2, 0x01e3, ["AE", "ae"]],
1141 [0x01e4, 0x01ed, ["G", "g", "G", "g", "K", "k", "O", "o", "O", "o"]],
1142 [0x01f0, 0x01f5, ["j", "D", "G", "g"]],
1143 [0x01fa, 0x01fb, ["A", "a"]], [0x01fc, 0x01fd, ["AE", "ae"]],
1144 [0x01fe, 0x0217, ["O", "o", "A", "a", "A", "a", "E", "e", "E", "e",
1145 "I", "i", "I", "i", "O", "o", "O", "o", "R", "r", "R", "r", "U",
1147 [0x0253, 0x0257, ["b", "c", "d", "d"]],
1148 [0x0260, 0x0269, ["g", "h", "h", "i", "i"]],
1149 [0x026b, 0x0273, ["l", "l", "l", "l", "m", "n", "n"]],
1150 [0x027c, 0x028b, ["r", "r", "r", "r", "s", "t", "u", "u", "v"]],
1151 [0x0290, 0x0291, ["z"]], [0x029d, 0x02a0, ["j", "q"]],
1152 [0x1e00, 0x1e09, ["A", "a", "B", "b", "B", "b", "B", "b", "C", "c"]],
1153 [0x1e0a, 0x1e13, ["D", "d"]], [0x1e14, 0x1e1d, ["E", "e"]],
1154 [0x1e1e, 0x1e21, ["F", "f", "G", "g"]], [0x1e22, 0x1e2b, ["H", "h"]],
1155 [0x1e2c, 0x1e8f, ["I", "i", "I", "i", "K", "k", "K", "k", "K", "k",
1156 "L", "l", "L", "l", "L", "l", "L", "l", "M", "m", "M", "m", "M",
1157 "m", "N", "n", "N", "n", "N", "n", "N", "n", "O", "o", "O", "o",
1158 "O", "o", "O", "o", "P", "p", "P", "p", "R", "r", "R", "r", "R",
1159 "r", "R", "r", "S", "s", "S", "s", "S", "s", "S", "s", "S", "s",
1160 "T", "t", "T", "t", "T", "t", "T", "t", "U", "u", "U", "u", "U",
1161 "u", "U", "u", "U", "u", "V", "v", "V", "v", "W", "w", "W", "w",
1162 "W", "w", "W", "w", "W", "w", "X", "x", "X", "x", "Y", "y"]],
1163 [0x1e90, 0x1e9a, ["Z", "z", "Z", "z", "Z", "z", "h", "t", "w", "y", "a"]],
1164 [0x1ea0, 0x1eb7, ["A", "a"]], [0x1eb8, 0x1ec7, ["E", "e"]],
1165 [0x1ec8, 0x1ecb, ["I", "i"]], [0x1ecc, 0x1ee3, ["O", "o"]],
1166 [0x1ee4, 0x1ef1, ["U", "u"]], [0x1ef2, 0x1ef9, ["Y", "y"]],
1167 [0x2071, 0x2071, ["i"]], [0x207f, 0x207f, ["n"]],
1168 [0x249c, 0x24b5, "a"], [0x24b6, 0x24cf, "A"],
1169 [0x24d0, 0x24e9, "a"],
1170 [0xfb00, 0xfb06, ["ff", "fi", "fl", "ffi", "ffl", "st", "st"]],
1171 [0xff21, 0xff3a, "A"], [0xff41, 0xff5a, "a"]
1172 ].forEach(function (start, stop, val) {
1173 if (typeof val != "string")
1174 for (let i = start; i <= stop; i++)
1175 table[String.fromCharCode(i)] = val[(i - start) % val.length];
1177 let n = val.charCodeAt(0);
1178 for (let i = start; i <= stop; i++)
1179 table[String.fromCharCode(i)] = String.fromCharCode(n + i - start);
1184 indexOf: function indexOf(dest, src) {
1185 let table = this.translitTable;
1186 var end = dest.length - src.length;
1187 if (src.length == 0)
1190 for (var i = 0; i < end; i++) {
1192 for (var k = 0; k < src.length;) {
1195 for (var l = 0; l < s.length; l++, k++) {
1198 if (k == src.length - 1)
1206 Mode: Struct("HintMode", "name", "prompt", "action", "filter")
1209 modes: function initModes() {
1210 initModes.require("commandline");
1211 modes.addMode("HINTS", {
1213 description: "Active when selecting elements with hints",
1214 bases: [modes.COMMAND_LINE],
1219 mappings: function () {
1220 let bind = function bind(names, description, action, params)
1221 mappings.add(config.browserModes, names, description,
1226 function () { hints.show("o"); });
1229 "Start Hints mode, but open link in a new tab",
1230 function () { hints.show(options.get("activate").has("links") ? "t" : "b"); });
1233 "Start an extended hints mode",
1234 function ({ count }) { hints.open(";", { count: count }); },
1238 "Start an extended hints mode and stay there until <Esc> is pressed",
1239 function ({ count }) { hints.open("g;", { continue: true, count: count }); },
1242 let bind = function bind(names, description, action, params)
1243 mappings.add([modes.HINTS], names, description,
1247 "Follow the selected hint",
1248 function ({ self }) { self.update(true); });
1251 "Focus the next matching hint",
1252 function ({ self }) { self.tab(false); });
1255 "Focus the previous matching hint",
1256 function ({ self }) { self.tab(true); });
1258 bind(["<BS>", "<C-h>"],
1259 "Delete the previous character",
1260 function ({ self }) self.backspace());
1263 "Toggle hint filtering",
1264 function ({ self }) { self.escapeNumbers = !self.escapeNumbers; });
1266 options: function () {
1267 options.add(["extendedhinttags", "eht"],
1268 "XPath or CSS selector strings of hintable elements for extended hint modes",
1271 "[asOTvVWy]": [":-moz-any-link", "area[href]", "img[src]", "iframe[src]"],
1273 "[F]": ["body", "code", "div", "html", "p", "pre", "span"],
1274 "[S]": ["input:not([type=hidden])", "textarea", "button", "select"]
1278 getKey: function (val, default_)
1279 let (res = array.nth(this.value, function (re) let (match = re.exec(val)) match && match[0] == val, 0))
1280 res ? res.matcher : default_,
1281 setter: function (vals) {
1282 for (let value in values(vals))
1283 value.matcher = DOM.compileMatcher(Option.splitList(value.result));
1286 validator: DOM.validateMatcher
1289 options.add(["hinttags", "ht"],
1290 "XPath or CSS selector strings of hintable elements for Hints mode",
1291 "stringlist", ":-moz-any-link,area,button,iframe,input:not([type=hidden]),select,textarea," +
1292 "[onclick],[onmouseover],[onmousedown],[onmouseup],[oncommand]," +
1293 "[tabindex],[role=link],[role=button],[contenteditable=true]",
1295 setter: function (values) {
1296 this.matcher = DOM.compileMatcher(values);
1299 validator: DOM.validateMatcher
1302 options.add(["hintkeys", "hk"],
1303 "The keys used to label and select hints",
1304 "string", "0123456789",
1307 "0123456789": "Numbers",
1308 "asdfg;lkjh": "Home Row"
1310 validator: function (value) {
1311 let values = DOM.Event.parse(value).map(DOM.Event.closure.stringify);
1312 return Option.validIf(array.uniq(values).length === values.length && values.length > 1,
1313 _("option.hintkeys.duplicate"));
1317 options.add(["hinttimeout", "hto"],
1318 "Timeout before automatically following a non-unique numerical hint",
1320 { validator: function (value) value >= 0 });
1322 options.add(["followhints", "fh"],
1323 "Define the conditions under which selected hints are followed",
1327 "0": "Follow the first hint as soon as typed text uniquely identifies it. Follow the selected hint on <Return>.",
1328 "1": "Follow the selected hint on <Return>.",
1332 options.add(["hintmatching", "hm"],
1333 "How hints are filtered",
1334 "stringlist", "contains",
1337 "contains": "The typed characters are split on whitespace. The resulting groups must all appear in the hint.",
1338 "custom": "Delegate to a custom function: dactyl.plugins.customHintMatcher(hintString)",
1339 "firstletters": "Behaves like wordstartswith, but all groups must match a sequence of words.",
1340 "wordstartswith": "The typed characters are split on whitespace. The resulting groups must all match the beginnings of words, in order.",
1341 "transliterated": UTF8("When true, special latin characters are translated to their ASCII equivalents (e.g., é ⇒ e)")
1343 validator: function (values) Option.validateCompleter.call(this, values) &&
1344 1 === values.reduce(function (acc, v) acc + (["contains", "custom", "firstletters", "wordstartswith"].indexOf(v) >= 0), 0)
1347 options.add(["wordseparators", "wsp"],
1348 "Regular expression defining which characters separate words when matching hints",
1349 "string", '[.,!?:;/"^$%&?()[\\]{}<>#*+|=~ _-]',
1350 { validator: function (value) RegExp(value) });
1352 options.add(["hintinputs", "hin"],
1353 "Which text is used to filter hints for input elements",
1354 "stringlist", "label,value",
1357 "value": "Match against the value of the input field",
1358 "label": "Match against the text of a label for the input field, if one can be found",
1359 "name": "Match against the name of the input field"
1365 // vim: set fdm=marker sw=4 ts=4 et: