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 if (!opts.window && modes.main == modes.OUTPUT_MULTILINE)
22 opts.window = commandline.widgets.multilineOutput.contentWindow;
24 this.hintMode = hints.modes[mode];
25 dactyl.assert(this.hintMode);
27 this.activeTimeout = null; // needed for hinttimeout > 0
28 this.continue = Boolean(opts.continue);
30 this.hintKeys = events.fromString(options["hintkeys"]).map(events.closure.toString);
32 this.hintString = opts.filter || "";
35 this.usedTabKey = false;
36 this.validHints = []; // store the indices of the "hints" array with valid elements
40 this.top = opts.window || content;
41 this.top.addEventListener("resize", hints.resizeTimer.closure.tell, true);
42 this.top.addEventListener("dactyl-commandupdate", hints.resizeTimer.closure.tell, false, true);
48 if (this.validHints.length == 0) {
52 else if (this.validHints.length == 1 && !this.continue)
59 get active() this._active,
63 this.span.setAttribute("active", true);
65 this.span.removeAttribute("active");
67 hints.setClass(this.elem, this.valid ? val : null);
69 hints.setClass(this.imgSpan, this.valid ? val : null);
72 get valid() this._valid,
76 this.span.style.display = (val ? "" : "none");
78 this.imgSpan.style.display = (val ? "" : "none");
80 this.active = this.active;
84 get mode() modes.HINTS,
86 get prompt() ["Question", UTF8(this.hintMode.prompt) + ": "],
88 leave: function leave(stack) {
89 leave.superapply(this, arguments);
92 if (hints.hintSession == this)
93 hints.hintSession = null;
95 this.top.removeEventListener("resize", hints.resizeTimer.closure.tell, true);
96 this.top.removeEventListener("dactyl-commandupdate", hints.resizeTimer.closure.tell, true);
103 checkUnique: function _checkUnique() {
104 if (this.hintNumber == 0)
106 dactyl.assert(this.hintNumber <= this.validHints.length);
108 // if we write a numeric part like 3, but we have 45 hints, only follow
109 // the hint after a timeout, as the user might have wanted to follow link 34
110 if (this.hintNumber > 0 && this.hintNumber * this.hintKeys.length <= this.validHints.length) {
111 let timeout = options["hinttimeout"];
113 this.activeTimeout = this.timeout(function () {
117 else // we have a unique hint
122 * Clear any timeout which might be active after pressing a number
124 clearTimeout: function () {
125 if (this.activeTimeout)
126 this.activeTimeout.cancel();
127 this.activeTimeout = null;
130 _escapeNumbers: false,
131 get escapeNumbers() this._escapeNumbers,
132 set escapeNumbers(val) {
134 this._escapeNumbers = !!val;
135 if (val && this.usedTabKey)
138 this.updateStatusline();
142 * Returns the hint string for a given number based on the values of
143 * the 'hintkeys' option.
145 * @param {number} n The number to transform.
148 getHintString: function getHintString(n) {
149 let res = [], len = this.hintKeys.length;
151 res.push(this.hintKeys[n % len]);
152 n = Math.floor(n / len);
155 return res.reverse().join("");
159 * Returns true if the given key string represents a
160 * pseudo-hint-number.
162 * @param {string} key The key to test.
163 * @returns {boolean} Whether the key represents a hint number.
165 isHintKey: function isHintKey(key) this.hintKeys.indexOf(key) >= 0,
168 * Gets the actual offset of an imagemap area.
170 * Only called by {@link #_generate}.
172 * @param {Object} elem The <area> element.
173 * @param {number} leftPos The left offset of the image.
174 * @param {number} topPos The top offset of the image.
175 * @returns [leftPos, topPos] The updated offsets.
177 getAreaOffset: function _getAreaOffset(elem, leftPos, topPos) {
179 // Need to add the offset to the area element.
180 // Always try to find the top-left point, as per dactyl default.
181 let shape = elem.getAttribute("shape").toLowerCase();
182 let coordStr = elem.getAttribute("coords");
183 // Technically it should be only commas, but hey
184 coordStr = coordStr.replace(/\s+[;,]\s+/g, ",").replace(/\s+/g, ",");
185 let coords = coordStr.split(",").map(Number);
187 if ((shape == "rect" || shape == "rectangle") && coords.length == 4) {
188 leftPos += coords[0];
191 else if (shape == "circle" && coords.length == 3) {
192 leftPos += coords[0] - coords[2] / Math.sqrt(2);
193 topPos += coords[1] - coords[2] / Math.sqrt(2);
195 else if ((shape == "poly" || shape == "polygon") && coords.length % 2 == 0) {
196 let leftBound = Infinity;
197 let topBound = Infinity;
199 // First find the top-left corner of the bounding rectangle (offset from image topleft can be noticeably suboptimal)
200 for (let i = 0; i < coords.length; i += 2) {
201 leftBound = Math.min(coords[i], leftBound);
202 topBound = Math.min(coords[i + 1], topBound);
207 let curDist = Infinity;
209 // Then find the closest vertex. (we could generalize to nearest point on an edge, but I doubt there is a need)
210 for (let i = 0; i < coords.length; i += 2) {
211 let leftOffset = coords[i] - leftBound;
212 let topOffset = coords[i + 1] - topBound;
213 let dist = Math.sqrt(leftOffset * leftOffset + topOffset * topOffset);
214 if (dist < curDist) {
217 curTop = coords[i + 1];
221 // If we found a satisfactory offset, let's use it.
222 if (curDist < Infinity)
223 return [leftPos + curLeft, topPos + curTop];
226 catch (e) {} // badly formed document, or shape == "default" in which case we don't move the hint
227 return [leftPos, topPos];
230 // the containing block offsets with respect to the viewport
231 getContainerOffsets: function _getContainerOffsets(doc) {
232 let body = doc.body || doc.documentElement;
233 // TODO: getComputedStyle returns null for Facebook channel_iframe doc - probable Gecko bug.
234 let style = util.computedStyle(body);
236 if (style && /^(absolute|fixed|relative)$/.test(style.position)) {
237 let rect = body.getClientRects()[0];
238 return [-rect.left, -rect.top];
241 return [doc.defaultView.scrollX, doc.defaultView.scrollY];
245 * Generate the hints in a window.
247 * Pushes the hints into the pageHints object, but does not display them.
249 * @param {Window} win The window for which to generate hints.
252 generate: function _generate(win, offsets) {
256 let doc = win.document;
258 let [offsetX, offsetY] = this.getContainerOffsets(doc);
260 offsets = offsets || { left: 0, right: 0, top: 0, bottom: 0 };
261 offsets.right = win.innerWidth - offsets.right;
262 offsets.bottom = win.innerHeight - offsets.bottom;
264 function isVisible(elem) {
265 let rect = elem.getBoundingClientRect();
266 if (!rect || !rect.width || !rect.height ||
267 rect.top > offsets.bottom || rect.bottom < offsets.top ||
268 rect.left > offsets.right || rect.right < offsets.left)
271 let computedStyle = doc.defaultView.getComputedStyle(elem, null);
272 if (computedStyle.visibility != "visible" || computedStyle.display == "none")
277 let body = doc.body || doc.querySelector("body");
279 let fragment = util.xmlToDom(<div highlight="hints"/>, doc);
280 body.appendChild(fragment);
281 util.computedStyle(fragment).height; // Force application of binding.
282 let container = doc.getAnonymousElementByAttribute(fragment, "anonid", "hints") || fragment;
284 let baseNodeAbsolute = util.xmlToDom(<span highlight="Hint" style="display: none"/>, doc);
286 let mode = this.hintMode;
287 let res = mode.matcher(doc);
289 let start = this.pageHints.length;
290 for (let elem in res) {
291 let hint = { elem: elem, showText: false, __proto__: this.Hint };
293 if (!isVisible(elem) || mode.filter && !mode.filter(elem))
296 if (elem.hasAttributeNS(NS, "hint"))
297 [hint.text, hint.showText] = [elem.getAttributeNS(NS, "hint"), true];
298 else if (isinstance(elem, [HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement]))
299 [hint.text, hint.showText] = hints.getInputHint(elem, doc);
300 else if (elem.firstElementChild instanceof HTMLImageElement && /^\s*$/.test(elem.textContent))
301 [hint.text, hint.showText] = [elem.firstElementChild.alt || elem.firstElementChild.title, true];
303 hint.text = elem.textContent.toLowerCase();
305 hint.span = baseNodeAbsolute.cloneNode(true);
307 let rect = elem.getClientRects()[0] || elem.getBoundingClientRect();
308 let leftPos = Math.max((rect.left + offsetX), offsetX);
309 let topPos = Math.max((rect.top + offsetY), offsetY);
311 if (elem instanceof HTMLAreaElement)
312 [leftPos, topPos] = this.getAreaOffset(elem, leftPos, topPos);
314 hint.span.style.left = leftPos + "px";
315 hint.span.style.top = topPos + "px";
316 container.appendChild(hint.span);
318 this.pageHints.push(hint);
321 this.docs.push({ doc: doc, start: start, end: this.pageHints.length - 1 });
324 Array.forEach(win.frames, function (f) {
325 if (isVisible(f.frameElement)) {
326 let rect = f.frameElement.getBoundingClientRect();
328 left: Math.max(offsets.left - rect.left, 0),
329 right: Math.max(rect.right - offsets.right, 0),
330 top: Math.max(offsets.top - rect.top, 0),
331 bottom: Math.max(rect.bottom - offsets.bottom, 0)
342 * Will update the filter on displayed hints and follow the final hint if
345 * @param {Event} event The keypress event.
347 onChange: function onChange(event) {
348 this.prevInput = "text";
353 this.hintString = commandline.command;
354 this.updateStatusline();
356 if (this.validHints.length == 1)
361 * Handle a hint mode event.
363 * @param {Event} event The event to handle.
365 onKeyPress: function onKeyPress(eventList) {
366 const KILL = false, PASS = true;
367 let key = events.toString(eventList[0]);
371 if (!this.escapeNumbers && this.isHintKey(key)) {
372 this.prevInput = "number";
374 let oldHintNumber = this.hintNumber;
375 if (this.usedTabKey) {
377 this.usedTabKey = false;
379 this.hintNumber = this.hintNumber * this.hintKeys.length +
380 this.hintKeys.indexOf(key);
382 this.updateStatusline();
384 if (this.docs.length)
385 this.updateValidNumbers();
391 this.showActiveHint(this.hintNumber, oldHintNumber || 1);
393 dactyl.assert(this.hintNumber != 0);
402 onResize: function () {
404 this.generate(this.top);
411 * Called when there are one or zero hints in order to possibly activate it
412 * and, if activated, to clean up the rest of the hinting system.
414 * @param {boolean} followFirst Whether to force the following of the first
415 * link (when 'followhints' is 1 or 2)
418 process: function _processHints(followFirst) {
419 dactyl.assert(this.validHints.length > 0);
421 // This "followhints" option is *too* confusing. For me, and
422 // presumably for users, too. --Kris
423 if (options["followhints"] > 0) {
425 return; // no return hit; don't examine uniqueness
427 // OK. return hit. But there's more than one hint, and
428 // there's no tab-selected current link. Do not follow in mode 2
429 dactyl.assert(options["followhints"] != 2 || this.validHints.length == 1 || this.hintNumber);
433 let firstHref = this.validHints[0].elem.getAttribute("href") || null;
435 if (this.validHints.some(function (h) h.elem.getAttribute("href") != firstHref))
438 else if (this.validHints.length > 1)
442 let timeout = followFirst || events.feedingKeys ? 0 : 500;
443 let activeIndex = (this.hintNumber ? this.hintNumber - 1 : 0);
444 let elem = this.validHints[activeIndex].elem;
450 this.removeHints(timeout);
454 let hinted = n || this.validHints.some(function (h) h.elem === elem);
456 hints.setClass(elem, null);
458 hints.setClass(elem, n % 2);
460 hints.setClass(elem, this.validHints[Math.max(0, this.hintNumber-1)].elem === elem);
463 this.timeout(next, 50);
466 if (!this.continue) {
469 modes.push(modes.IGNORE, modes.HINTS);
472 this.timeout(function () {
473 if ((modes.extended & modes.HINTS) && !this.continue)
475 commandline.lastEcho = null; // Hack.
476 dactyl.trapErrors("action", this.hintMode,
477 elem, elem.href || elem.src || "",
478 this.extendedhintCount, top);
479 if (this.continue && this.top)
485 * Remove all hints from the document, and reset the completions.
487 * Lingers on the active hint briefly to confirm the selection to the user.
489 * @param {number} timeout The number of milliseconds before the active
492 removeHints: function _removeHints(timeout) {
493 for (let { doc, start, end } in values(this.docs)) {
494 for (let elem in util.evaluateXPath("//*[@dactyl:highlight='hints']", doc))
495 elem.parentNode.removeChild(elem);
496 for (let i in util.range(start, end + 1))
497 this.pageHints[i].valid = false;
499 styles.system.remove("hint-positions");
504 reset: function reset() {
506 this.validHints = [];
510 _reset: function _reset() {
511 if (!this.usedTabKey)
513 if (this.continue && this.validHints.length <= 1) {
514 this.hintString = "";
515 commandline.widgets.command = this.hintString;
518 this.updateStatusline();
522 * Display the hints in pageHints that are still valid.
524 show: function _show() {
526 let validHint = hints.hintMatcher(this.hintString.toLowerCase());
527 let activeHint = this.hintNumber || 1;
528 this.validHints = [];
530 for (let { doc, start, end } in values(this.docs)) {
531 let [offsetX, offsetY] = this.getContainerOffsets(doc);
534 for (let i in (util.interruptibleRange(start, end + 1, 500))) {
535 let hint = this.pageHints[i];
537 hint.valid = validHint(hint.text);
541 if (hint.text == "" && hint.elem.firstChild && hint.elem.firstChild instanceof HTMLImageElement) {
543 let rect = hint.elem.firstChild.getBoundingClientRect();
547 hint.imgSpan = util.xmlToDom(<span highlight="Hint" dactyl:hl="HintImage" xmlns:dactyl={NS}/>, doc);
548 hint.imgSpan.style.display = "none";
549 hint.imgSpan.style.left = (rect.left + offsetX) + "px";
550 hint.imgSpan.style.top = (rect.top + offsetY) + "px";
551 hint.imgSpan.style.width = (rect.right - rect.left) + "px";
552 hint.imgSpan.style.height = (rect.bottom - rect.top) + "px";
553 hint.span.parentNode.appendChild(hint.imgSpan);
557 let str = this.getHintString(hintnum);
559 if (hint.elem instanceof HTMLInputElement)
560 if (hint.elem.type === "radio")
561 text.push(UTF8(hint.elem.checked ? "⊙" : "○"));
562 else if (hint.elem.type === "checkbox")
563 text.push(UTF8(hint.elem.checked ? "☑" : "☐"));
565 text.push(hint.text.substr(0, 50));
567 hint.span.setAttribute("text", str + (text.length ? ": " + text.join(" ") : ""));
568 hint.span.setAttribute("number", str);
570 hint.imgSpan.setAttribute("number", str);
571 hint.active = activeHint == hintnum;
572 this.validHints.push(hint);
577 if (options["usermode"]) {
579 for (let hint in values(this.pageHints)) {
580 let selector = highlight.selector("Hint") + "[number=" + hint.span.getAttribute("number").quote() + "]";
581 let imgSpan = "[dactyl|hl=HintImage]";
582 css.push(selector + ":not(" + imgSpan + ") { " + hint.span.style.cssText + " }");
584 css.push(selector + imgSpan + " { " + hint.span.style.cssText + " }");
586 styles.system.add("hint-positions", "*", css.join("\n"));
593 * Update the activeHint.
595 * By default highlights it green instead of yellow.
597 * @param {number} newId The hint to make active.
598 * @param {number} oldId The currently active hint.
600 showActiveHint: function _showActiveHint(newId, oldId) {
601 let oldHint = this.validHints[oldId - 1];
603 oldHint.active = false;
605 let newHint = this.validHints[newId - 1];
607 newHint.active = true;
610 backspace: function () {
612 if (this.prevInput !== "number")
615 if (this.hintNumber > 0 && !this.usedTabKey) {
616 this.hintNumber = Math.floor(this.hintNumber / this.hintKeys.length);
617 if (this.hintNumber == 0)
618 this.prevInput = "text";
622 this.usedTabKey = false;
629 updateValidNumbers: function updateValidNumbers(always) {
630 let string = this.getHintString(this.hintNumber);
631 for (let hint in values(this.validHints))
632 hint.valid = always || hint.span.getAttribute("number").indexOf(string) == 0;
635 tab: function tab(previous) {
637 this.usedTabKey = true;
638 if (this.hintNumber == 0)
641 let oldId = this.hintNumber;
643 if (++this.hintNumber > this.validHints.length)
647 if (--this.hintNumber < 1)
648 this.hintNumber = this.validHints.length;
651 this.updateValidNumbers(true);
652 this.showActiveHint(this.hintNumber, oldId);
653 this.updateStatusline();
656 update: function update(followFirst) {
658 this.updateStatusline();
660 if (this.docs.length == 0 && this.hintString.length > 0)
664 this.process(followFirst);
668 * Display the current status to the user.
670 updateStatusline: function _updateStatusline() {
671 statusline.inputBuffer = (this.escapeNumbers ? options["mapleader"] : "") +
672 (this.hintNumber ? this.getHintString(this.hintNumber) : "");
676 var Hints = Module("hints", {
677 init: function init() {
678 this.resizeTimer = Timer(100, 500, function () {
679 if (isinstance(modes.main, modes.HINTS))
680 modes.getStack(0).params.onResize();
683 let appContent = document.getElementById("appcontent");
685 events.listen(appContent, "scroll", this.resizeTimer.closure.tell, false);
687 const Mode = Hints.Mode;
688 Mode.defaultValue("tags", function () function () options.get("hinttags").matcher);
689 Mode.prototype.__defineGetter__("matcher", function ()
690 options.get("extendedhinttags").getKey(this.name, this.tags()));
693 this.addMode(";", "Focus hint", buffer.closure.focusElement);
694 this.addMode("?", "Show information for hint", function (elem) buffer.showElementInfo(elem));
695 this.addMode("s", "Save hint", function (elem) buffer.saveLink(elem, false));
696 this.addMode("f", "Focus frame", function (elem) dactyl.focus(elem.ownerDocument.defaultView));
697 this.addMode("F", "Focus frame or pseudo-frame", buffer.closure.focusElement, null, isScrollable);
698 this.addMode("o", "Follow hint", function (elem) buffer.followLink(elem, dactyl.CURRENT_TAB));
699 this.addMode("t", "Follow hint in a new tab", function (elem) buffer.followLink(elem, dactyl.NEW_TAB));
700 this.addMode("b", "Follow hint in a background tab", function (elem) buffer.followLink(elem, dactyl.NEW_BACKGROUND_TAB));
701 this.addMode("w", "Follow hint in a new window", function (elem) buffer.followLink(elem, dactyl.NEW_WINDOW));
702 this.addMode("O", "Generate an ‘:open URL’ prompt", function (elem, loc) CommandExMode().open("open " + loc));
703 this.addMode("T", "Generate a ‘:tabopen URL’ prompt", function (elem, loc) CommandExMode().open("tabopen " + loc));
704 this.addMode("W", "Generate a ‘:winopen URL’ prompt", function (elem, loc) CommandExMode().open("winopen " + loc));
705 this.addMode("a", "Add a bookmark", function (elem) bookmarks.addSearchKeyword(elem));
706 this.addMode("S", "Add a search keyword", function (elem) bookmarks.addSearchKeyword(elem));
707 this.addMode("v", "View hint source", function (elem, loc) buffer.viewSource(loc, false));
708 this.addMode("V", "View hint source in external editor", function (elem, loc) buffer.viewSource(loc, true));
709 this.addMode("y", "Yank hint location", function (elem, loc) dactyl.clipboardWrite(loc, true));
710 this.addMode("Y", "Yank hint description", function (elem) dactyl.clipboardWrite(elem.textContent || "", true));
711 this.addMode("c", "Open context menu", function (elem) buffer.openContextMenu(elem));
712 this.addMode("i", "Show image", function (elem) dactyl.open(elem.src));
713 this.addMode("I", "Show image in a new tab", function (elem) dactyl.open(elem.src, dactyl.NEW_TAB));
715 function isScrollable(elem) isinstance(elem, [HTMLFrameElement, HTMLIFrameElement]) ||
716 Buffer.isScrollable(elem, 0, true) || Buffer.isScrollable(elem, 0, false);
719 hintSession: Modes.boundProperty(),
722 * Creates a new hint mode.
724 * @param {string} mode The letter that identifies this mode.
725 * @param {string} prompt The description to display to the user
727 * @param {function(Node)} action The function to be called with the
728 * element that matches.
729 * @param {function():string} tags The function that returns an
730 * XPath expression to decide which elements can be hinted (the
731 * default returns options["hinttags"]).
734 addMode: function (mode, prompt, action, tags) {
735 arguments[1] = UTF8(prompt);
736 this.modes[mode] = Hints.Mode.apply(Hints.Mode, arguments);
740 * Get a hint for "input", "textarea" and "select".
742 * Tries to use <label>s if possible but does not try to guess that a
743 * neighboring element might look like a label. Only called by
744 * {@link #_generate}.
746 * If it finds a hint it returns it, if the hint is not the caption of the
747 * element it will return showText=true.
749 * @param {Object} elem The element used to generate hint text.
750 * @param {Document} doc The containing document.
752 * @returns [text, showText]
754 getInputHint: function _getInputHint(elem, doc) {
755 // <input type="submit|button|reset"/> Always use the value
756 // <input type="radio|checkbox"/> Use the value if it is not numeric or label or name
757 // <input type="password"/> Never use the value, use label or name
758 // <input type="text|file"/> <textarea/> Use value if set or label or name
759 // <input type="image"/> Use the alt text if present (showText) or label or name
760 // <input type="hidden"/> Never gets here
761 // <select/> Use the text of the selected item or label or name
763 let type = elem.type;
765 if (elem instanceof HTMLInputElement && set.has(util.editableInputs, elem.type))
766 return [elem.value, false];
768 for (let [, option] in Iterator(options["hintinputs"])) {
769 if (option == "value") {
770 if (elem instanceof HTMLSelectElement) {
771 if (elem.selectedIndex >= 0)
772 return [elem.item(elem.selectedIndex).text.toLowerCase(), false];
774 else if (type == "image") {
776 return [elem.alt.toLowerCase(), true];
778 else if (elem.value && type != "password") {
779 // radio's and checkboxes often use internal ids as values - maybe make this an option too...
780 if (! ((type == "radio" || type == "checkbox") && !isNaN(elem.value)))
781 return [elem.value.toLowerCase(), (type == "radio" || type == "checkbox")];
784 else if (option == "label") {
786 // TODO: (possibly) do some guess work for label-like objects
787 let label = util.evaluateXPath(["label[@for=" + elem.id.quote() + "]"], doc).snapshotItem(0);
789 return [label.textContent.toLowerCase(), true];
792 else if (option == "name")
793 return [elem.name.toLowerCase(), true];
801 * Get the hintMatcher according to user preference.
803 * @param {string} hintString The currently typed hint.
804 * @returns {hintMatcher}
806 hintMatcher: function _hintMatcher(hintString) { //{{{
808 * Divide a string by a regular expression.
810 * @param {RegExp|string} pat The pattern to split on.
811 * @param {string} str The string to split.
812 * @returns {Array(string)} The lowercased splits of the splitting.
814 function tokenize(pat, str) str.split(pat).map(String.toLowerCase);
817 * Get a hint matcher for hintmatching=contains
819 * The hintMatcher expects the user input to be space delimited and it
820 * returns true if each set of characters typed can be found, in any
821 * order, in the link.
823 * @param {string} hintString The string typed by the user.
824 * @returns {function(String):boolean} A function that takes the text
825 * of a hint and returns true if all the (space-delimited) sets of
826 * characters typed by the user can be found in it.
828 function containsMatcher(hintString) { //{{{
829 let tokens = tokenize(/\s+/, hintString);
830 return function (linkText) {
831 linkText = linkText.toLowerCase();
832 return tokens.every(function (token) indexOf(linkText, token) >= 0);
837 * Get a hintMatcher for hintmatching=firstletters|wordstartswith
839 * The hintMatcher will look for any division of the user input that
840 * would match the first letters of words. It will always only match
843 * @param {string} hintString The string typed by the user.
844 * @param {boolean} allowWordOverleaping Whether to allow non-contiguous
846 * @returns {function(String):boolean} A function that will filter only
847 * hints that match as above.
849 function wordStartsWithMatcher(hintString, allowWordOverleaping) { //{{{
850 let hintStrings = tokenize(/\s+/, hintString);
851 let wordSplitRegexp = util.regexp(options["wordseparators"]);
854 * Match a set of characters to the start of words.
856 * What the **** does this do? --Kris
857 * This function matches hintStrings like 'hekho' to links
858 * like 'Hey Kris, how are you?' -> [HE]y [K]ris [HO]w are you
861 * @param {string} chars The characters to match.
862 * @param {Array(string)} words The words to match them against.
863 * @param {boolean} allowWordOverleaping Whether words may be
864 * skipped during matching.
865 * @returns {boolean} Whether a match can be found.
867 function charsAtBeginningOfWords(chars, words, allowWordOverleaping) {
868 function charMatches(charIdx, chars, wordIdx, words, inWordIdx, allowWordOverleaping) {
869 let matches = (chars[charIdx] == words[wordIdx][inWordIdx]);
870 if ((matches == false && allowWordOverleaping) || words[wordIdx].length == 0) {
871 let nextWordIdx = wordIdx + 1;
872 if (nextWordIdx == words.length)
875 return charMatches(charIdx, chars, nextWordIdx, words, 0, allowWordOverleaping);
879 let nextCharIdx = charIdx + 1;
880 if (nextCharIdx == chars.length)
883 let nextWordIdx = wordIdx + 1;
884 let beyondLastWord = (nextWordIdx == words.length);
885 let charMatched = false;
886 if (beyondLastWord == false)
887 charMatched = charMatches(nextCharIdx, chars, nextWordIdx, words, 0, allowWordOverleaping);
892 if (charMatched == false || beyondLastWord == true) {
893 let nextInWordIdx = inWordIdx + 1;
894 if (nextInWordIdx == words[wordIdx].length)
897 return charMatches(nextCharIdx, chars, wordIdx, words, nextInWordIdx, allowWordOverleaping);
904 return charMatches(0, chars, 0, words, 0, allowWordOverleaping);
908 * Check whether the array of strings all exist at the start of the
911 * i.e. ['ro', 'e'] would match ['rollover', 'effect']
913 * The matches must be in order, and, if allowWordOverleaping is
916 * @param {Array(string)} strings The strings to search for.
917 * @param {Array(string)} words The words to search in.
918 * @param {boolean} allowWordOverleaping Whether matches may be
920 * @returns {boolean} Whether all the strings matched.
922 function stringsAtBeginningOfWords(strings, words, allowWordOverleaping) {
924 for (let [, word] in Iterator(words)) {
925 if (word.length == 0)
928 let str = strings[strIdx];
929 if (str.length == 0 || indexOf(word, str) == 0)
931 else if (!allowWordOverleaping)
934 if (strIdx == strings.length)
938 for (; strIdx < strings.length; strIdx++) {
939 if (strings[strIdx].length != 0)
945 return function (linkText) {
946 if (hintStrings.length == 1 && hintStrings[0].length == 0)
949 let words = tokenize(wordSplitRegexp, linkText);
950 if (hintStrings.length == 1)
951 return charsAtBeginningOfWords(hintStrings[0], words, allowWordOverleaping);
953 return stringsAtBeginningOfWords(hintStrings, words, allowWordOverleaping);
957 let indexOf = String.indexOf;
958 if (options.get("hintmatching").has("transliterated"))
959 indexOf = Hints.indexOf;
961 switch (options["hintmatching"][0]) {
962 case "contains" : return containsMatcher(hintString);
963 case "wordstartswith": return wordStartsWithMatcher(hintString, true);
964 case "firstletters" : return wordStartsWithMatcher(hintString, false);
965 case "custom" : return dactyl.plugins.customHintMatcher(hintString);
966 default : dactyl.echoerr(_("hints.noMatcher", hintMatching));
971 open: function open(mode, opts) {
972 this._extendedhintCount = opts.count;
973 commandline.input(["Normal", mode], "", {
974 completer: function (context) {
975 context.compare = function () 0;
976 context.completions = [[k, v.prompt] for ([k, v] in Iterator(hints.modes))];
978 onSubmit: function (arg) {
980 hints.show(arg, opts);
982 onChange: function () {
983 this.accepted = true;
990 * Toggle the highlight of a hint.
992 * @param {Object} elem The element to toggle.
993 * @param {boolean} active Whether it is the currently active hint or not.
995 setClass: function _setClass(elem, active) {
996 if (elem.dactylHighlight == null)
997 elem.dactylHighlight = elem.getAttributeNS(NS, "highlight") || "";
999 let prefix = (elem.getAttributeNS(NS, "hl") || "") + " " + elem.dactylHighlight + " ";
1001 highlight.highlightNode(elem, prefix + "HintActive");
1002 else if (active != null)
1003 highlight.highlightNode(elem, prefix + "HintElem");
1005 highlight.highlightNode(elem, elem.dactylHighlight);
1006 // delete elem.dactylHighlight fails on Gecko 1.9. Issue #197
1007 elem.dactylHighlight = null;
1011 show: function show(mode, opts) {
1012 this.hintSession = HintSession(mode, opts);
1015 translitTable: Class.memoize(function () {
1018 [0x00c0, 0x00c6, ["A"]], [0x00c7, 0x00c7, ["C"]],
1019 [0x00c8, 0x00cb, ["E"]], [0x00cc, 0x00cf, ["I"]],
1020 [0x00d1, 0x00d1, ["N"]], [0x00d2, 0x00d6, ["O"]],
1021 [0x00d8, 0x00d8, ["O"]], [0x00d9, 0x00dc, ["U"]],
1022 [0x00dd, 0x00dd, ["Y"]], [0x00e0, 0x00e6, ["a"]],
1023 [0x00e7, 0x00e7, ["c"]], [0x00e8, 0x00eb, ["e"]],
1024 [0x00ec, 0x00ef, ["i"]], [0x00f1, 0x00f1, ["n"]],
1025 [0x00f2, 0x00f6, ["o"]], [0x00f8, 0x00f8, ["o"]],
1026 [0x00f9, 0x00fc, ["u"]], [0x00fd, 0x00fd, ["y"]],
1027 [0x00ff, 0x00ff, ["y"]], [0x0100, 0x0105, ["A", "a"]],
1028 [0x0106, 0x010d, ["C", "c"]], [0x010e, 0x0111, ["D", "d"]],
1029 [0x0112, 0x011b, ["E", "e"]], [0x011c, 0x0123, ["G", "g"]],
1030 [0x0124, 0x0127, ["H", "h"]], [0x0128, 0x0130, ["I", "i"]],
1031 [0x0132, 0x0133, ["IJ", "ij"]], [0x0134, 0x0135, ["J", "j"]],
1032 [0x0136, 0x0136, ["K", "k"]], [0x0139, 0x0142, ["L", "l"]],
1033 [0x0143, 0x0148, ["N", "n"]], [0x0149, 0x0149, ["n"]],
1034 [0x014c, 0x0151, ["O", "o"]], [0x0152, 0x0153, ["OE", "oe"]],
1035 [0x0154, 0x0159, ["R", "r"]], [0x015a, 0x0161, ["S", "s"]],
1036 [0x0162, 0x0167, ["T", "t"]], [0x0168, 0x0173, ["U", "u"]],
1037 [0x0174, 0x0175, ["W", "w"]], [0x0176, 0x0178, ["Y", "y", "Y"]],
1038 [0x0179, 0x017e, ["Z", "z"]], [0x0180, 0x0183, ["b", "B", "B", "b"]],
1039 [0x0187, 0x0188, ["C", "c"]], [0x0189, 0x0189, ["D"]],
1040 [0x018a, 0x0192, ["D", "D", "d", "F", "f"]],
1041 [0x0193, 0x0194, ["G"]],
1042 [0x0197, 0x019b, ["I", "K", "k", "l", "l"]],
1043 [0x019d, 0x01a1, ["N", "n", "O", "O", "o"]],
1044 [0x01a4, 0x01a5, ["P", "p"]], [0x01ab, 0x01ab, ["t"]],
1045 [0x01ac, 0x01b0, ["T", "t", "T", "U", "u"]],
1046 [0x01b2, 0x01d2, ["V", "Y", "y", "Z", "z", "D", "L", "N", "A", "a",
1047 "I", "i", "O", "o"]],
1048 [0x01d3, 0x01dc, ["U", "u"]], [0x01de, 0x01e1, ["A", "a"]],
1049 [0x01e2, 0x01e3, ["AE", "ae"]],
1050 [0x01e4, 0x01ed, ["G", "g", "G", "g", "K", "k", "O", "o", "O", "o"]],
1051 [0x01f0, 0x01f5, ["j", "D", "G", "g"]],
1052 [0x01fa, 0x01fb, ["A", "a"]], [0x01fc, 0x01fd, ["AE", "ae"]],
1053 [0x01fe, 0x0217, ["O", "o", "A", "a", "A", "a", "E", "e", "E", "e",
1054 "I", "i", "I", "i", "O", "o", "O", "o", "R", "r", "R", "r", "U",
1056 [0x0253, 0x0257, ["b", "c", "d", "d"]],
1057 [0x0260, 0x0269, ["g", "h", "h", "i", "i"]],
1058 [0x026b, 0x0273, ["l", "l", "l", "l", "m", "n", "n"]],
1059 [0x027c, 0x028b, ["r", "r", "r", "r", "s", "t", "u", "u", "v"]],
1060 [0x0290, 0x0291, ["z"]], [0x029d, 0x02a0, ["j", "q"]],
1061 [0x1e00, 0x1e09, ["A", "a", "B", "b", "B", "b", "B", "b", "C", "c"]],
1062 [0x1e0a, 0x1e13, ["D", "d"]], [0x1e14, 0x1e1d, ["E", "e"]],
1063 [0x1e1e, 0x1e21, ["F", "f", "G", "g"]], [0x1e22, 0x1e2b, ["H", "h"]],
1064 [0x1e2c, 0x1e8f, ["I", "i", "I", "i", "K", "k", "K", "k", "K", "k",
1065 "L", "l", "L", "l", "L", "l", "L", "l", "M", "m", "M", "m", "M",
1066 "m", "N", "n", "N", "n", "N", "n", "N", "n", "O", "o", "O", "o",
1067 "O", "o", "O", "o", "P", "p", "P", "p", "R", "r", "R", "r", "R",
1068 "r", "R", "r", "S", "s", "S", "s", "S", "s", "S", "s", "S", "s",
1069 "T", "t", "T", "t", "T", "t", "T", "t", "U", "u", "U", "u", "U",
1070 "u", "U", "u", "U", "u", "V", "v", "V", "v", "W", "w", "W", "w",
1071 "W", "w", "W", "w", "W", "w", "X", "x", "X", "x", "Y", "y"]],
1072 [0x1e90, 0x1e9a, ["Z", "z", "Z", "z", "Z", "z", "h", "t", "w", "y", "a"]],
1073 [0x1ea0, 0x1eb7, ["A", "a"]], [0x1eb8, 0x1ec7, ["E", "e"]],
1074 [0x1ec8, 0x1ecb, ["I", "i"]], [0x1ecc, 0x1ee3, ["O", "o"]],
1075 [0x1ee4, 0x1ef1, ["U", "u"]], [0x1ef2, 0x1ef9, ["Y", "y"]],
1076 [0x2071, 0x2071, ["i"]], [0x207f, 0x207f, ["n"]],
1077 [0x249c, 0x24b5, "a"], [0x24b6, 0x24cf, "A"],
1078 [0x24d0, 0x24e9, "a"],
1079 [0xfb00, 0xfb06, ["ff", "fi", "fl", "ffi", "ffl", "st", "st"]],
1080 [0xff21, 0xff3a, "A"], [0xff41, 0xff5a, "a"]
1081 ].forEach(function (start, stop, val) {
1082 if (typeof val != "string")
1083 for (let i = start; i <= stop; i++)
1084 table[String.fromCharCode(i)] = val[(i - start) % val.length];
1086 let n = val.charCodeAt(0);
1087 for (let i = start; i <= stop; i++)
1088 table[String.fromCharCode(i)] = String.fromCharCode(n + i - start);
1093 indexOf: function indexOf(dest, src) {
1094 let table = this.translitTable;
1095 var end = dest.length - src.length;
1096 if (src.length == 0)
1099 for (var i = 0; i < end; i++) {
1101 for (var k = 0; k < src.length;) {
1104 for (var l = 0; l < s.length; l++, k++) {
1107 if (k == src.length - 1)
1115 Mode: Struct("HintMode", "name", "prompt", "action", "tags", "filter")
1118 modes: function initModes() {
1119 initModes.require("commandline");
1120 modes.addMode("HINTS", {
1122 description: "Active when selecting elements in QuickHint or ExtendedHint mode",
1123 bases: [modes.COMMAND_LINE],
1128 mappings: function () {
1129 var myModes = config.browserModes.concat(modes.OUTPUT_MULTILINE);
1130 mappings.add(myModes, ["f"],
1131 "Start QuickHint mode",
1132 function () { hints.show("o"); });
1134 mappings.add(myModes, ["F"],
1135 "Start QuickHint mode, but open link in a new tab",
1136 function () { hints.show(options.get("activate").has("links") ? "t" : "b"); });
1138 mappings.add(myModes, [";"],
1139 "Start an extended hint mode",
1140 function ({ count }) { hints.open(";", { count: count }); },
1143 mappings.add(myModes, ["g;"],
1144 "Start an extended hint mode and stay there until <Esc> is pressed",
1145 function ({ count }) { hints.open("g;", { continue: true, count: count }); },
1148 mappings.add(modes.HINTS, ["<Return>"],
1149 "Follow the selected hint",
1150 function ({ self }) { self.update(true); });
1152 mappings.add(modes.HINTS, ["<Tab>"],
1153 "Focus the next matching hint",
1154 function ({ self }) { self.tab(false); });
1156 mappings.add(modes.HINTS, ["<S-Tab>"],
1157 "Focus the previous matching hint",
1158 function ({ self }) { self.tab(true); });
1160 mappings.add(modes.HINTS, ["<BS>", "<C-h>"],
1161 "Delete the previous character",
1162 function ({ self }) self.backspace());
1164 mappings.add(modes.HINTS, ["<Leader>"],
1165 "Toggle hint filtering",
1166 function ({ self }) { self.escapeNumbers = !self.escapeNumbers; });
1168 options: function () {
1169 function xpath(arg) util.makeXPath(arg);
1171 options.add(["extendedhinttags", "eht"],
1172 "XPath or CSS selector strings of hintable elements for extended hint modes",
1175 "[asOTivVWy]": ["a[href]", "area[href]", "img[src]", "iframe[src]"],
1177 "[F]": ["body", "code", "div", "html", "p", "pre", "span"],
1178 "[S]": ["input:not([type=hidden])", "textarea", "button", "select"]
1182 getKey: function (val, default_)
1183 let (res = array.nth(this.value, function (re) re.test(val), 0))
1184 res ? res.matcher : default_,
1185 setter: function (vals) {
1186 for (let value in values(vals))
1187 value.matcher = util.compileMatcher(Option.splitList(value.result));
1190 validator: util.validateMatcher
1193 options.add(["hinttags", "ht"],
1194 "XPath string of hintable elements activated by 'f' and 'F'",
1195 "stringlist", "input:not([type=hidden]),a,area,iframe,textarea,button,select," +
1196 "[onclick],[onmouseover],[onmousedown],[onmouseup],[oncommand]," +
1197 "[tabindex],[role=link],[role=button]",
1199 setter: function (values) {
1200 this.matcher = util.compileMatcher(values);
1203 validator: util.validateMatcher
1206 options.add(["hintkeys", "hk"],
1207 "The keys used to label and select hints",
1208 "string", "0123456789",
1211 "0123456789": "Numbers",
1212 "asdfg;lkjh": "Home Row"
1214 validator: function (value) {
1215 let values = events.fromString(value).map(events.closure.toString);
1216 return Option.validIf(array.uniq(values).length === values.length,
1217 "Duplicate keys not allowed");
1221 options.add(["hinttimeout", "hto"],
1222 "Timeout before automatically following a non-unique numerical hint",
1224 { validator: function (value) value >= 0 });
1226 options.add(["followhints", "fh"],
1227 // FIXME: this description isn't very clear but I can't think of a
1228 // better one right now.
1229 "Change the behavior of <Return> in hint mode",
1233 "0": "Follow the first hint as soon as typed text uniquely identifies it. Follow the selected hint on <Return>.",
1234 "1": "Follow the selected hint on <Return>.",
1235 "2": "Follow the selected hint on <Return> only it's been <Tab>-selected."
1239 options.add(["hintmatching", "hm"],
1240 "How hints are filtered",
1241 "stringlist", "contains",
1244 "contains": "The typed characters are split on whitespace. The resulting groups must all appear in the hint.",
1245 "custom": "Delegate to a custom function: dactyl.plugins.customHintMatcher(hintString)",
1246 "firstletters": "Behaves like wordstartswith, but all groups must match a sequence of words.",
1247 "wordstartswith": "The typed characters are split on whitespace. The resulting groups must all match the beginnings of words, in order.",
1248 "transliterated": UTF8("When true, special latin characters are translated to their ASCII equivalents (e.g., é ⇒ e)")
1250 validator: function (values) Option.validateCompleter.call(this, values) &&
1251 1 === values.reduce(function (acc, v) acc + (["contains", "custom", "firstletters", "wordstartswith"].indexOf(v) >= 0), 0)
1254 options.add(["wordseparators", "wsp"],
1255 "Regular expression defining which characters separate words when matching hints",
1256 "string", '[.,!?:;/"^$%&?()[\\]{}<>#*+|=~ _-]',
1257 { validator: function (value) RegExp(value) });
1259 options.add(["hintinputs", "hin"],
1260 "Which text is used to filter hints for input elements",
1261 "stringlist", "label,value",
1264 "value": "Match against the value of the input field",
1265 "label": "Match against the text of a label for the input field, if one can be found",
1266 "name": "Match against the name of the input field"
1272 // vim: set fdm=marker sw=4 ts=4 et: