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", 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 if (hints.hintSession == this)
102 hints.hintSession = null;
104 this.top.removeEventListener("resize", this.closure._onResize, true);
105 this.top.removeEventListener("dactyl-commandupdate", this.closure._onResize, true);
112 checkUnique: function _checkUnique() {
113 if (this.hintNumber == 0)
115 dactyl.assert(this.hintNumber <= this.validHints.length);
117 // if we write a numeric part like 3, but we have 45 hints, only follow
118 // the hint after a timeout, as the user might have wanted to follow link 34
119 if (this.hintNumber > 0 && this.hintNumber * this.hintKeys.length <= this.validHints.length) {
120 let timeout = options["hinttimeout"];
122 this.activeTimeout = this.timeout(function () {
126 else // we have a unique hint
131 * Clear any timeout which might be active after pressing a number
133 clearTimeout: function () {
134 if (this.activeTimeout)
135 this.activeTimeout.cancel();
136 this.activeTimeout = null;
139 _escapeNumbers: false,
140 get escapeNumbers() this._escapeNumbers,
141 set escapeNumbers(val) {
143 this._escapeNumbers = !!val;
144 if (val && this.usedTabKey)
147 this.updateStatusline();
151 * Returns the hint string for a given number based on the values of
152 * the 'hintkeys' option.
154 * @param {number} n The number to transform.
157 getHintString: function getHintString(n) {
158 let res = [], len = this.hintKeys.length;
160 res.push(this.hintKeys[n % len]);
161 n = Math.floor(n / len);
164 return res.reverse().join("");
168 * The reverse of {@link #getHintString}. Given a hint string,
171 * @param {string} str The hint's string.
172 * @returns {number} The hint's index.
174 getHintNumber: function getHintNumber(str) {
175 let base = this.hintKeys.length;
177 for (let char in values(str))
178 res = res * base + this.hintKeys.indexOf(char);
183 * Returns true if the given key string represents a
184 * pseudo-hint-number.
186 * @param {string} key The key to test.
187 * @returns {boolean} Whether the key represents a hint number.
189 isHintKey: function isHintKey(key) this.hintKeys.indexOf(key) >= 0,
192 * Gets the actual offset of an imagemap area.
194 * Only called by {@link #_generate}.
196 * @param {Object} elem The <area> element.
197 * @param {number} leftPos The left offset of the image.
198 * @param {number} topPos The top offset of the image.
199 * @returns [leftPos, topPos] The updated offsets.
201 getAreaOffset: function _getAreaOffset(elem, leftPos, topPos) {
203 // Need to add the offset to the area element.
204 // Always try to find the top-left point, as per dactyl default.
205 let shape = elem.getAttribute("shape").toLowerCase();
206 let coordStr = elem.getAttribute("coords");
207 // Technically it should be only commas, but hey
208 coordStr = coordStr.replace(/\s+[;,]\s+/g, ",").replace(/\s+/g, ",");
209 let coords = coordStr.split(",").map(Number);
211 if ((shape == "rect" || shape == "rectangle") && coords.length == 4) {
212 leftPos += coords[0];
215 else if (shape == "circle" && coords.length == 3) {
216 leftPos += coords[0] - coords[2] / Math.sqrt(2);
217 topPos += coords[1] - coords[2] / Math.sqrt(2);
219 else if ((shape == "poly" || shape == "polygon") && coords.length % 2 == 0) {
220 let leftBound = Infinity;
221 let topBound = Infinity;
223 // First find the top-left corner of the bounding rectangle (offset from image topleft can be noticeably suboptimal)
224 for (let i = 0; i < coords.length; i += 2) {
225 leftBound = Math.min(coords[i], leftBound);
226 topBound = Math.min(coords[i + 1], topBound);
231 let curDist = Infinity;
233 // Then find the closest vertex. (we could generalize to nearest point on an edge, but I doubt there is a need)
234 for (let i = 0; i < coords.length; i += 2) {
235 let leftOffset = coords[i] - leftBound;
236 let topOffset = coords[i + 1] - topBound;
237 let dist = Math.sqrt(leftOffset * leftOffset + topOffset * topOffset);
238 if (dist < curDist) {
241 curTop = coords[i + 1];
245 // If we found a satisfactory offset, let's use it.
246 if (curDist < Infinity)
247 return [leftPos + curLeft, topPos + curTop];
250 catch (e) {} // badly formed document, or shape == "default" in which case we don't move the hint
251 return [leftPos, topPos];
254 // the containing block offsets with respect to the viewport
255 getContainerOffsets: function _getContainerOffsets(doc) {
256 let body = doc.body || doc.documentElement;
257 // TODO: getComputedStyle returns null for Facebook channel_iframe doc - probable Gecko bug.
258 let style = util.computedStyle(body);
260 if (style && /^(absolute|fixed|relative)$/.test(style.position)) {
261 let rect = body.getClientRects()[0];
262 return [-rect.left, -rect.top];
265 return [doc.defaultView.scrollX, doc.defaultView.scrollY];
269 * Generate the hints in a window.
271 * Pushes the hints into the pageHints object, but does not display them.
273 * @param {Window} win The window for which to generate hints.
276 generate: function _generate(win, offsets) {
280 let doc = win.document;
282 memoize(doc, "dactylLabels", function ()
283 iter([l.getAttribute("for"), l]
284 for (l in array.iterValues(doc.querySelectorAll("label[for]"))))
287 let [offsetX, offsetY] = this.getContainerOffsets(doc);
289 offsets = offsets || { left: 0, right: 0, top: 0, bottom: 0 };
290 offsets.right = win.innerWidth - offsets.right;
291 offsets.bottom = win.innerHeight - offsets.bottom;
293 function isVisible(elem) {
294 let rect = elem.getBoundingClientRect();
296 rect.top > offsets.bottom || rect.bottom < offsets.top ||
297 rect.left > offsets.right || rect.right < offsets.left)
300 if (!rect.width || !rect.height)
301 if (!Array.some(elem.childNodes, function (elem) elem instanceof Element && util.computedStyle(elem).float != "none" && isVisible(elem)))
304 let computedStyle = doc.defaultView.getComputedStyle(elem, null);
305 if (computedStyle.visibility != "visible" || computedStyle.display == "none")
310 let body = doc.body || doc.querySelector("body");
312 let fragment = util.xmlToDom(<div highlight="hints"/>, doc);
313 body.appendChild(fragment);
314 util.computedStyle(fragment).height; // Force application of binding.
315 let container = doc.getAnonymousElementByAttribute(fragment, "anonid", "hints") || fragment;
317 let baseNodeAbsolute = util.xmlToDom(<span highlight="Hint" style="display: none;"/>, doc);
319 let mode = this.hintMode;
320 let res = mode.matcher(doc);
322 let start = this.pageHints.length;
324 for (let elem in res)
325 if (isVisible(elem) && (!mode.filter || mode.filter(elem)))
328 rect: elem.getClientRects()[0] || elem.getBoundingClientRect(),
333 for (let hint in values(_hints)) {
334 let { elem, rect } = hint;
336 if (elem.hasAttributeNS(NS, "hint"))
337 [hint.text, hint.showText] = [elem.getAttributeNS(NS, "hint"), true];
338 else if (isinstance(elem, [HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement]))
339 [hint.text, hint.showText] = hints.getInputHint(elem, doc);
340 else if (elem.firstElementChild instanceof HTMLImageElement && /^\s*$/.test(elem.textContent))
341 [hint.text, hint.showText] = [elem.firstElementChild.alt || elem.firstElementChild.title, true];
343 hint.text = elem.textContent.toLowerCase();
345 hint.span = baseNodeAbsolute.cloneNode(false);
347 let leftPos = Math.max((rect.left + offsetX), offsetX);
348 let topPos = Math.max((rect.top + offsetY), offsetY);
350 if (elem instanceof HTMLAreaElement)
351 [leftPos, topPos] = this.getAreaOffset(elem, leftPos, topPos);
353 hint.span.setAttribute("style", ["display: none; left:", leftPos, "px; top:", topPos, "px"].join(""));
354 container.appendChild(hint.span);
356 this.pageHints.push(hint);
359 this.docs.push({ doc: doc, start: start, end: this.pageHints.length - 1 });
362 Array.forEach(win.frames, function (f) {
363 if (isVisible(f.frameElement)) {
364 let rect = f.frameElement.getBoundingClientRect();
366 left: Math.max(offsets.left - rect.left, 0),
367 right: Math.max(rect.right - offsets.right, 0),
368 top: Math.max(offsets.top - rect.top, 0),
369 bottom: Math.max(rect.bottom - offsets.bottom, 0)
380 * Will update the filter on displayed hints and follow the final hint if
383 * @param {Event} event The keypress event.
385 onChange: function onChange(event) {
386 this.prevInput = "text";
391 this.hintString = commandline.command;
392 this.updateStatusline();
394 if (this.validHints.length == 1)
399 * Handle a hints mode event.
401 * @param {Event} event The event to handle.
403 onKeyPress: function onKeyPress(eventList) {
404 const KILL = false, PASS = true;
405 let key = events.toString(eventList[0]);
409 if (!this.escapeNumbers && this.isHintKey(key)) {
410 this.prevInput = "number";
412 let oldHintNumber = this.hintNumber;
413 if (this.usedTabKey) {
415 this.usedTabKey = false;
417 this.hintNumber = this.hintNumber * this.hintKeys.length +
418 this.hintKeys.indexOf(key);
420 this.updateStatusline();
422 if (this.docs.length)
423 this.updateValidNumbers();
429 this.showActiveHint(this.hintNumber, oldHintNumber || 1);
431 dactyl.assert(this.hintNumber != 0);
440 onResize: function onResize() {
442 this.generate(this.top);
446 _onResize: function _onResize() {
448 hints.resizeTimer.tell();
454 * Called when there are one or zero hints in order to possibly activate it
455 * and, if activated, to clean up the rest of the hinting system.
457 * @param {boolean} followFirst Whether to force the following of the first
458 * link (when 'followhints' is 1 or 2)
461 process: function _processHints(followFirst) {
462 dactyl.assert(this.validHints.length > 0);
464 // This "followhints" option is *too* confusing. For me, and
465 // presumably for users, too. --Kris
466 if (options["followhints"] > 0 && !followFirst)
467 return; // no return hit; don't examine uniqueness
470 let firstHref = this.validHints[0].elem.getAttribute("href") || null;
472 if (this.validHints.some(function (h) h.elem.getAttribute("href") != firstHref))
475 else if (this.validHints.length > 1)
479 let timeout = followFirst || events.feedingKeys ? 0 : 500;
480 let activeIndex = (this.hintNumber ? this.hintNumber - 1 : 0);
481 let elem = this.validHints[activeIndex].elem;
487 this.removeHints(timeout);
491 let hinted = n || this.validHints.some(function (h) h.elem === elem);
493 hints.setClass(elem, null);
495 hints.setClass(elem, n % 2);
497 hints.setClass(elem, this.validHints[Math.max(0, this.hintNumber - 1)].elem === elem);
500 this.timeout(next, 50);
503 if (!this.continue) {
506 modes.push(modes.IGNORE, modes.HINTS);
509 dactyl.trapErrors("action", this.hintMode,
510 elem, elem.href || elem.src || "",
511 this.extendedhintCount, top);
513 this.timeout(function () {
514 if (modes.main == modes.IGNORE && !this.continue)
516 commandline.lastEcho = null; // Hack.
517 if (this.continue && this.top)
523 * Remove all hints from the document, and reset the completions.
525 * Lingers on the active hint briefly to confirm the selection to the user.
527 * @param {number} timeout The number of milliseconds before the active
530 removeHints: function _removeHints(timeout) {
531 for (let { doc, start, end } in values(this.docs)) {
532 // Goddamn stupid fucking Gecko 1.x security manager bullshit.
533 try { delete doc.dactylLabels; } catch (e) { doc.dactylLabels = undefined; }
535 for (let elem in util.evaluateXPath("//*[@dactyl:highlight='hints']", doc))
536 elem.parentNode.removeChild(elem);
537 for (let i in util.range(start, end + 1)) {
538 this.pageHints[i].ambiguous = false;
539 this.pageHints[i].valid = false;
542 styles.system.remove("hint-positions");
547 reset: function reset() {
549 this.validHints = [];
553 _reset: function _reset() {
554 if (!this.usedTabKey)
556 if (this.continue && this.validHints.length <= 1) {
557 this.hintString = "";
558 commandline.widgets.command = this.hintString;
561 this.updateStatusline();
565 * Display the hints in pageHints that are still valid.
567 show: function _show() {
569 let validHint = hints.hintMatcher(this.hintString.toLowerCase());
570 let activeHint = this.hintNumber || 1;
571 this.validHints = [];
573 for (let { doc, start, end } in values(this.docs)) {
574 let [offsetX, offsetY] = this.getContainerOffsets(doc);
577 for (let i in (util.interruptibleRange(start, end + 1, 500))) {
578 let hint = this.pageHints[i];
580 hint.valid = validHint(hint.text);
584 if (hint.text == "" && hint.elem.firstChild && hint.elem.firstChild instanceof HTMLImageElement) {
586 let rect = hint.elem.firstChild.getBoundingClientRect();
590 hint.imgSpan = util.xmlToDom(<span highlight="Hint" dactyl:hl="HintImage" xmlns:dactyl={NS}/>, doc);
591 hint.imgSpan.style.display = "none";
592 hint.imgSpan.style.left = (rect.left + offsetX) + "px";
593 hint.imgSpan.style.top = (rect.top + offsetY) + "px";
594 hint.imgSpan.style.width = (rect.right - rect.left) + "px";
595 hint.imgSpan.style.height = (rect.bottom - rect.top) + "px";
596 hint.span.parentNode.appendChild(hint.imgSpan);
600 let str = this.getHintString(hintnum);
602 if (hint.elem instanceof HTMLInputElement)
603 if (hint.elem.type === "radio")
604 text.push(UTF8(hint.elem.checked ? "⊙" : "○"));
605 else if (hint.elem.type === "checkbox")
606 text.push(UTF8(hint.elem.checked ? "☑" : "☐"));
607 if (hint.showText && !/^\s*$/.test(hint.text))
608 text.push(hint.text.substr(0, 50));
610 hint.span.setAttribute("text", str + (text.length ? ": " + text.join(" ") : ""));
611 hint.span.setAttribute("number", str);
613 hint.imgSpan.setAttribute("number", str);
615 hint.active = activeHint == hintnum;
617 this.validHints.push(hint);
622 let base = this.hintKeys.length;
623 for (let [i, hint] in Iterator(this.validHints))
624 hint.ambiguous = (i + 1) * base <= this.validHints.length;
626 if (options["usermode"]) {
628 for (let hint in values(this.pageHints)) {
629 let selector = highlight.selector("Hint") + "[number=" + hint.span.getAttribute("number").quote() + "]";
630 let imgSpan = "[dactyl|hl=HintImage]";
631 css.push(selector + ":not(" + imgSpan + ") { " + hint.span.style.cssText + " }");
633 css.push(selector + imgSpan + " { " + hint.span.style.cssText + " }");
635 styles.system.add("hint-positions", "*", css.join("\n"));
642 * Update the activeHint.
644 * By default highlights it green instead of yellow.
646 * @param {number} newId The hint to make active.
647 * @param {number} oldId The currently active hint.
649 showActiveHint: function _showActiveHint(newId, oldId) {
650 let oldHint = this.validHints[oldId - 1];
652 oldHint.active = false;
654 let newHint = this.validHints[newId - 1];
656 newHint.active = true;
659 backspace: function () {
661 if (this.prevInput !== "number")
664 if (this.hintNumber > 0 && !this.usedTabKey) {
665 this.hintNumber = Math.floor(this.hintNumber / this.hintKeys.length);
666 if (this.hintNumber == 0)
667 this.prevInput = "text";
671 this.usedTabKey = false;
678 updateValidNumbers: function updateValidNumbers(always) {
679 let string = this.getHintString(this.hintNumber);
680 for (let hint in values(this.validHints))
681 hint.valid = always || hint.span.getAttribute("number").indexOf(string) == 0;
684 tab: function tab(previous) {
686 this.usedTabKey = true;
687 if (this.hintNumber == 0)
690 let oldId = this.hintNumber;
692 if (++this.hintNumber > this.validHints.length)
696 if (--this.hintNumber < 1)
697 this.hintNumber = this.validHints.length;
700 this.updateValidNumbers(true);
701 this.showActiveHint(this.hintNumber, oldId);
702 this.updateStatusline();
705 update: function update(followFirst) {
707 this.updateStatusline();
709 if (this.docs.length == 0 && this.hintString.length > 0)
713 this.process(followFirst);
717 * Display the current status to the user.
719 updateStatusline: function _updateStatusline() {
720 statusline.inputBuffer = (this.escapeNumbers ? options["mapleader"] : "") +
721 (this.hintNumber ? this.getHintString(this.hintNumber) : "");
725 var Hints = Module("hints", {
726 init: function init() {
727 this.resizeTimer = Timer(100, 500, function () {
728 if (isinstance(modes.main, modes.HINTS))
729 modes.getStack(0).params.onResize();
732 let appContent = document.getElementById("appcontent");
734 events.listen(appContent, "scroll", this.resizeTimer.closure.tell, false);
736 const Mode = Hints.Mode;
737 Mode.prototype.__defineGetter__("matcher", function ()
738 options.get("extendedhinttags").getKey(this.name, options.get("hinttags").matcher));
741 this.addMode(";", "Focus hint", buffer.closure.focusElement);
742 this.addMode("?", "Show information for hint", function (elem) buffer.showElementInfo(elem));
743 this.addMode("s", "Save hint", function (elem) buffer.saveLink(elem, false));
744 this.addMode("f", "Focus frame", function (elem) dactyl.focus(elem.ownerDocument.defaultView));
745 this.addMode("F", "Focus frame or pseudo-frame", buffer.closure.focusElement, isScrollable);
746 this.addMode("o", "Follow hint", function (elem) buffer.followLink(elem, dactyl.CURRENT_TAB));
747 this.addMode("t", "Follow hint in a new tab", function (elem) buffer.followLink(elem, dactyl.NEW_TAB));
748 this.addMode("b", "Follow hint in a background tab", function (elem) buffer.followLink(elem, dactyl.NEW_BACKGROUND_TAB));
749 this.addMode("w", "Follow hint in a new window", function (elem) buffer.followLink(elem, dactyl.NEW_WINDOW));
750 this.addMode("O", "Generate an ‘:open URL’ prompt", function (elem, loc) CommandExMode().open("open " + loc));
751 this.addMode("T", "Generate a ‘:tabopen URL’ prompt", function (elem, loc) CommandExMode().open("tabopen " + loc));
752 this.addMode("W", "Generate a ‘:winopen URL’ prompt", function (elem, loc) CommandExMode().open("winopen " + loc));
753 this.addMode("a", "Add a bookmark", function (elem) bookmarks.addSearchKeyword(elem));
754 this.addMode("S", "Add a search keyword", function (elem) bookmarks.addSearchKeyword(elem));
755 this.addMode("v", "View hint source", function (elem, loc) buffer.viewSource(loc, false));
756 this.addMode("V", "View hint source in external editor", function (elem, loc) buffer.viewSource(loc, true));
757 this.addMode("y", "Yank hint location", function (elem, loc) dactyl.clipboardWrite(loc, true));
758 this.addMode("Y", "Yank hint description", function (elem) dactyl.clipboardWrite(elem.textContent || "", true));
759 this.addMode("c", "Open context menu", function (elem) buffer.openContextMenu(elem));
760 this.addMode("i", "Show image", function (elem) dactyl.open(elem.src));
761 this.addMode("I", "Show image in a new tab", function (elem) dactyl.open(elem.src, dactyl.NEW_TAB));
763 function isScrollable(elem) isinstance(elem, [HTMLFrameElement, HTMLIFrameElement]) ||
764 Buffer.isScrollable(elem, 0, true) || Buffer.isScrollable(elem, 0, false);
767 hintSession: Modes.boundProperty(),
770 * Creates a new hints mode.
772 * @param {string} mode The letter that identifies this mode.
773 * @param {string} prompt The description to display to the user
775 * @param {function(Node)} action The function to be called with the
776 * element that matches.
777 * @param {function(Node):boolean} filter A function used to filter
778 * the returned node set.
779 * @param {[string]} tags A value to add to the default
780 * 'extendedhinttags' value for this mode.
783 addMode: function (mode, prompt, action, filter, tags) {
784 function toString(regexp) RegExp.prototype.toString.call(regexp);
787 let eht = options.get("extendedhinttags");
788 let update = eht.isDefault;
790 let value = eht.parse(Option.quote(util.regexp.escape(mode)) + ":" + tags.map(Option.quote))[0];
791 eht.defaultValue = eht.defaultValue.filter(function (re) toString(re) != toString(value))
798 this.modes[mode] = Hints.Mode(mode, UTF8(prompt), action, filter);
802 * Get a hint for "input", "textarea" and "select".
804 * Tries to use <label>s if possible but does not try to guess that a
805 * neighboring element might look like a label. Only called by
806 * {@link #_generate}.
808 * If it finds a hint it returns it, if the hint is not the caption of the
809 * element it will return showText=true.
811 * @param {Object} elem The element used to generate hint text.
812 * @param {Document} doc The containing document.
814 * @returns [text, showText]
816 getInputHint: function _getInputHint(elem, doc) {
817 // <input type="submit|button|reset"/> Always use the value
818 // <input type="radio|checkbox"/> Use the value if it is not numeric or label or name
819 // <input type="password"/> Never use the value, use label or name
820 // <input type="text|file"/> <textarea/> Use value if set or label or name
821 // <input type="image"/> Use the alt text if present (showText) or label or name
822 // <input type="hidden"/> Never gets here
823 // <select/> Use the text of the selected item or label or name
825 let type = elem.type;
827 if (elem instanceof HTMLInputElement && Set.has(util.editableInputs, elem.type))
828 return [elem.value, false];
830 for (let [, option] in Iterator(options["hintinputs"])) {
831 if (option == "value") {
832 if (elem instanceof HTMLSelectElement) {
833 if (elem.selectedIndex >= 0)
834 return [elem.item(elem.selectedIndex).text.toLowerCase(), false];
836 else if (type == "image") {
838 return [elem.alt.toLowerCase(), true];
840 else if (elem.value && type != "password") {
841 // radios and checkboxes often use internal ids as values - maybe make this an option too...
842 if (! ((type == "radio" || type == "checkbox") && !isNaN(elem.value)))
843 return [elem.value.toLowerCase(), (type == "radio" || type == "checkbox")];
846 else if (option == "label") {
848 let label = elem.ownerDocument.dactylLabels[elem.id];
850 return [label.textContent.toLowerCase(), true];
853 else if (option == "name")
854 return [elem.name.toLowerCase(), true];
862 * Get the hintMatcher according to user preference.
864 * @param {string} hintString The currently typed hint.
865 * @returns {hintMatcher}
867 hintMatcher: function _hintMatcher(hintString) { //{{{
869 * Divide a string by a regular expression.
871 * @param {RegExp|string} pat The pattern to split on.
872 * @param {string} str The string to split.
873 * @returns {Array(string)} The lowercased splits of the splitting.
875 function tokenize(pat, str) str.split(pat).map(String.toLowerCase);
878 * Get a hint matcher for hintmatching=contains
880 * The hintMatcher expects the user input to be space delimited and it
881 * returns true if each set of characters typed can be found, in any
882 * order, in the link.
884 * @param {string} hintString The string typed by the user.
885 * @returns {function(String):boolean} A function that takes the text
886 * of a hint and returns true if all the (space-delimited) sets of
887 * characters typed by the user can be found in it.
889 function containsMatcher(hintString) { //{{{
890 let tokens = tokenize(/\s+/, hintString);
891 return function (linkText) {
892 linkText = linkText.toLowerCase();
893 return tokens.every(function (token) indexOf(linkText, token) >= 0);
898 * Get a hintMatcher for hintmatching=firstletters|wordstartswith
900 * The hintMatcher will look for any division of the user input that
901 * would match the first letters of words. It will always only match
904 * @param {string} hintString The string typed by the user.
905 * @param {boolean} allowWordOverleaping Whether to allow non-contiguous
907 * @returns {function(String):boolean} A function that will filter only
908 * hints that match as above.
910 function wordStartsWithMatcher(hintString, allowWordOverleaping) { //{{{
911 let hintStrings = tokenize(/\s+/, hintString);
912 let wordSplitRegexp = util.regexp(options["wordseparators"]);
915 * Match a set of characters to the start of words.
917 * What the **** does this do? --Kris
918 * This function matches hintStrings like 'hekho' to links
919 * like 'Hey Kris, how are you?' -> [HE]y [K]ris [HO]w are you
922 * @param {string} chars The characters to match.
923 * @param {Array(string)} words The words to match them against.
924 * @param {boolean} allowWordOverleaping Whether words may be
925 * skipped during matching.
926 * @returns {boolean} Whether a match can be found.
928 function charsAtBeginningOfWords(chars, words, allowWordOverleaping) {
929 function charMatches(charIdx, chars, wordIdx, words, inWordIdx, allowWordOverleaping) {
930 let matches = (chars[charIdx] == words[wordIdx][inWordIdx]);
931 if ((matches == false && allowWordOverleaping) || words[wordIdx].length == 0) {
932 let nextWordIdx = wordIdx + 1;
933 if (nextWordIdx == words.length)
936 return charMatches(charIdx, chars, nextWordIdx, words, 0, allowWordOverleaping);
940 let nextCharIdx = charIdx + 1;
941 if (nextCharIdx == chars.length)
944 let nextWordIdx = wordIdx + 1;
945 let beyondLastWord = (nextWordIdx == words.length);
946 let charMatched = false;
947 if (beyondLastWord == false)
948 charMatched = charMatches(nextCharIdx, chars, nextWordIdx, words, 0, allowWordOverleaping);
953 if (charMatched == false || beyondLastWord == true) {
954 let nextInWordIdx = inWordIdx + 1;
955 if (nextInWordIdx == words[wordIdx].length)
958 return charMatches(nextCharIdx, chars, wordIdx, words, nextInWordIdx, allowWordOverleaping);
965 return charMatches(0, chars, 0, words, 0, allowWordOverleaping);
969 * Check whether the array of strings all exist at the start of the
972 * i.e. ['ro', 'e'] would match ['rollover', 'effect']
974 * The matches must be in order, and, if allowWordOverleaping is
977 * @param {Array(string)} strings The strings to search for.
978 * @param {Array(string)} words The words to search in.
979 * @param {boolean} allowWordOverleaping Whether matches may be
981 * @returns {boolean} Whether all the strings matched.
983 function stringsAtBeginningOfWords(strings, words, allowWordOverleaping) {
985 for (let [, word] in Iterator(words)) {
986 if (word.length == 0)
989 let str = strings[strIdx];
990 if (str.length == 0 || indexOf(word, str) == 0)
992 else if (!allowWordOverleaping)
995 if (strIdx == strings.length)
999 for (; strIdx < strings.length; strIdx++) {
1000 if (strings[strIdx].length != 0)
1006 return function (linkText) {
1007 if (hintStrings.length == 1 && hintStrings[0].length == 0)
1010 let words = tokenize(wordSplitRegexp, linkText);
1011 if (hintStrings.length == 1)
1012 return charsAtBeginningOfWords(hintStrings[0], words, allowWordOverleaping);
1014 return stringsAtBeginningOfWords(hintStrings, words, allowWordOverleaping);
1018 let indexOf = String.indexOf;
1019 if (options.get("hintmatching").has("transliterated"))
1020 indexOf = Hints.indexOf;
1022 switch (options["hintmatching"][0]) {
1023 case "contains" : return containsMatcher(hintString);
1024 case "wordstartswith": return wordStartsWithMatcher(hintString, true);
1025 case "firstletters" : return wordStartsWithMatcher(hintString, false);
1026 case "custom" : return dactyl.plugins.customHintMatcher(hintString);
1027 default : dactyl.echoerr(_("hints.noMatcher", hintMatching));
1032 open: function open(mode, opts) {
1033 this._extendedhintCount = opts.count;
1034 commandline.input(["Normal", mode], "", {
1035 autocomplete: false,
1036 completer: function (context) {
1037 context.compare = function () 0;
1038 context.completions = [[k, v.prompt] for ([k, v] in Iterator(hints.modes))];
1040 onSubmit: function (arg) {
1042 hints.show(arg, opts);
1044 onChange: function (arg) {
1045 if (Object.keys(hints.modes).some(function (m) m != arg && m.indexOf(arg) == 0))
1048 this.accepted = true;
1055 * Toggle the highlight of a hint.
1057 * @param {Object} elem The element to toggle.
1058 * @param {boolean} active Whether it is the currently active hint or not.
1060 setClass: function _setClass(elem, active) {
1061 if (elem.dactylHighlight == null)
1062 elem.dactylHighlight = elem.getAttributeNS(NS, "highlight") || "";
1064 let prefix = (elem.getAttributeNS(NS, "hl") || "") + " " + elem.dactylHighlight + " ";
1066 highlight.highlightNode(elem, prefix + "HintActive");
1067 else if (active != null)
1068 highlight.highlightNode(elem, prefix + "HintElem");
1070 highlight.highlightNode(elem, elem.dactylHighlight);
1071 // delete elem.dactylHighlight fails on Gecko 1.9. Issue #197
1072 elem.dactylHighlight = null;
1076 show: function show(mode, opts) {
1077 this.hintSession = HintSession(mode, opts);
1080 translitTable: Class.memoize(function () {
1083 [0x00c0, 0x00c6, ["A"]], [0x00c7, 0x00c7, ["C"]],
1084 [0x00c8, 0x00cb, ["E"]], [0x00cc, 0x00cf, ["I"]],
1085 [0x00d1, 0x00d1, ["N"]], [0x00d2, 0x00d6, ["O"]],
1086 [0x00d8, 0x00d8, ["O"]], [0x00d9, 0x00dc, ["U"]],
1087 [0x00dd, 0x00dd, ["Y"]], [0x00e0, 0x00e6, ["a"]],
1088 [0x00e7, 0x00e7, ["c"]], [0x00e8, 0x00eb, ["e"]],
1089 [0x00ec, 0x00ef, ["i"]], [0x00f1, 0x00f1, ["n"]],
1090 [0x00f2, 0x00f6, ["o"]], [0x00f8, 0x00f8, ["o"]],
1091 [0x00f9, 0x00fc, ["u"]], [0x00fd, 0x00fd, ["y"]],
1092 [0x00ff, 0x00ff, ["y"]], [0x0100, 0x0105, ["A", "a"]],
1093 [0x0106, 0x010d, ["C", "c"]], [0x010e, 0x0111, ["D", "d"]],
1094 [0x0112, 0x011b, ["E", "e"]], [0x011c, 0x0123, ["G", "g"]],
1095 [0x0124, 0x0127, ["H", "h"]], [0x0128, 0x0130, ["I", "i"]],
1096 [0x0132, 0x0133, ["IJ", "ij"]], [0x0134, 0x0135, ["J", "j"]],
1097 [0x0136, 0x0136, ["K", "k"]], [0x0139, 0x0142, ["L", "l"]],
1098 [0x0143, 0x0148, ["N", "n"]], [0x0149, 0x0149, ["n"]],
1099 [0x014c, 0x0151, ["O", "o"]], [0x0152, 0x0153, ["OE", "oe"]],
1100 [0x0154, 0x0159, ["R", "r"]], [0x015a, 0x0161, ["S", "s"]],
1101 [0x0162, 0x0167, ["T", "t"]], [0x0168, 0x0173, ["U", "u"]],
1102 [0x0174, 0x0175, ["W", "w"]], [0x0176, 0x0178, ["Y", "y", "Y"]],
1103 [0x0179, 0x017e, ["Z", "z"]], [0x0180, 0x0183, ["b", "B", "B", "b"]],
1104 [0x0187, 0x0188, ["C", "c"]], [0x0189, 0x0189, ["D"]],
1105 [0x018a, 0x0192, ["D", "D", "d", "F", "f"]],
1106 [0x0193, 0x0194, ["G"]],
1107 [0x0197, 0x019b, ["I", "K", "k", "l", "l"]],
1108 [0x019d, 0x01a1, ["N", "n", "O", "O", "o"]],
1109 [0x01a4, 0x01a5, ["P", "p"]], [0x01ab, 0x01ab, ["t"]],
1110 [0x01ac, 0x01b0, ["T", "t", "T", "U", "u"]],
1111 [0x01b2, 0x01d2, ["V", "Y", "y", "Z", "z", "D", "L", "N", "A", "a",
1112 "I", "i", "O", "o"]],
1113 [0x01d3, 0x01dc, ["U", "u"]], [0x01de, 0x01e1, ["A", "a"]],
1114 [0x01e2, 0x01e3, ["AE", "ae"]],
1115 [0x01e4, 0x01ed, ["G", "g", "G", "g", "K", "k", "O", "o", "O", "o"]],
1116 [0x01f0, 0x01f5, ["j", "D", "G", "g"]],
1117 [0x01fa, 0x01fb, ["A", "a"]], [0x01fc, 0x01fd, ["AE", "ae"]],
1118 [0x01fe, 0x0217, ["O", "o", "A", "a", "A", "a", "E", "e", "E", "e",
1119 "I", "i", "I", "i", "O", "o", "O", "o", "R", "r", "R", "r", "U",
1121 [0x0253, 0x0257, ["b", "c", "d", "d"]],
1122 [0x0260, 0x0269, ["g", "h", "h", "i", "i"]],
1123 [0x026b, 0x0273, ["l", "l", "l", "l", "m", "n", "n"]],
1124 [0x027c, 0x028b, ["r", "r", "r", "r", "s", "t", "u", "u", "v"]],
1125 [0x0290, 0x0291, ["z"]], [0x029d, 0x02a0, ["j", "q"]],
1126 [0x1e00, 0x1e09, ["A", "a", "B", "b", "B", "b", "B", "b", "C", "c"]],
1127 [0x1e0a, 0x1e13, ["D", "d"]], [0x1e14, 0x1e1d, ["E", "e"]],
1128 [0x1e1e, 0x1e21, ["F", "f", "G", "g"]], [0x1e22, 0x1e2b, ["H", "h"]],
1129 [0x1e2c, 0x1e8f, ["I", "i", "I", "i", "K", "k", "K", "k", "K", "k",
1130 "L", "l", "L", "l", "L", "l", "L", "l", "M", "m", "M", "m", "M",
1131 "m", "N", "n", "N", "n", "N", "n", "N", "n", "O", "o", "O", "o",
1132 "O", "o", "O", "o", "P", "p", "P", "p", "R", "r", "R", "r", "R",
1133 "r", "R", "r", "S", "s", "S", "s", "S", "s", "S", "s", "S", "s",
1134 "T", "t", "T", "t", "T", "t", "T", "t", "U", "u", "U", "u", "U",
1135 "u", "U", "u", "U", "u", "V", "v", "V", "v", "W", "w", "W", "w",
1136 "W", "w", "W", "w", "W", "w", "X", "x", "X", "x", "Y", "y"]],
1137 [0x1e90, 0x1e9a, ["Z", "z", "Z", "z", "Z", "z", "h", "t", "w", "y", "a"]],
1138 [0x1ea0, 0x1eb7, ["A", "a"]], [0x1eb8, 0x1ec7, ["E", "e"]],
1139 [0x1ec8, 0x1ecb, ["I", "i"]], [0x1ecc, 0x1ee3, ["O", "o"]],
1140 [0x1ee4, 0x1ef1, ["U", "u"]], [0x1ef2, 0x1ef9, ["Y", "y"]],
1141 [0x2071, 0x2071, ["i"]], [0x207f, 0x207f, ["n"]],
1142 [0x249c, 0x24b5, "a"], [0x24b6, 0x24cf, "A"],
1143 [0x24d0, 0x24e9, "a"],
1144 [0xfb00, 0xfb06, ["ff", "fi", "fl", "ffi", "ffl", "st", "st"]],
1145 [0xff21, 0xff3a, "A"], [0xff41, 0xff5a, "a"]
1146 ].forEach(function (start, stop, val) {
1147 if (typeof val != "string")
1148 for (let i = start; i <= stop; i++)
1149 table[String.fromCharCode(i)] = val[(i - start) % val.length];
1151 let n = val.charCodeAt(0);
1152 for (let i = start; i <= stop; i++)
1153 table[String.fromCharCode(i)] = String.fromCharCode(n + i - start);
1158 indexOf: function indexOf(dest, src) {
1159 let table = this.translitTable;
1160 var end = dest.length - src.length;
1161 if (src.length == 0)
1164 for (var i = 0; i < end; i++) {
1166 for (var k = 0; k < src.length;) {
1169 for (var l = 0; l < s.length; l++, k++) {
1172 if (k == src.length - 1)
1180 Mode: Struct("HintMode", "name", "prompt", "action", "filter")
1183 modes: function initModes() {
1184 initModes.require("commandline");
1185 modes.addMode("HINTS", {
1187 description: "Active when selecting elements with hints",
1188 bases: [modes.COMMAND_LINE],
1193 mappings: function () {
1194 var myModes = config.browserModes.concat(modes.OUTPUT_MULTILINE);
1195 mappings.add(myModes, ["f"],
1197 function () { hints.show("o"); });
1199 mappings.add(myModes, ["F"],
1200 "Start Hints mode, but open link in a new tab",
1201 function () { hints.show(options.get("activate").has("links") ? "t" : "b"); });
1203 mappings.add(myModes, [";"],
1204 "Start an extended hints mode",
1205 function ({ count }) { hints.open(";", { count: count }); },
1208 mappings.add(myModes, ["g;"],
1209 "Start an extended hints mode and stay there until <Esc> is pressed",
1210 function ({ count }) { hints.open("g;", { continue: true, count: count }); },
1213 mappings.add(modes.HINTS, ["<Return>"],
1214 "Follow the selected hint",
1215 function ({ self }) { self.update(true); });
1217 mappings.add(modes.HINTS, ["<Tab>"],
1218 "Focus the next matching hint",
1219 function ({ self }) { self.tab(false); });
1221 mappings.add(modes.HINTS, ["<S-Tab>"],
1222 "Focus the previous matching hint",
1223 function ({ self }) { self.tab(true); });
1225 mappings.add(modes.HINTS, ["<BS>", "<C-h>"],
1226 "Delete the previous character",
1227 function ({ self }) self.backspace());
1229 mappings.add(modes.HINTS, ["<Leader>"],
1230 "Toggle hint filtering",
1231 function ({ self }) { self.escapeNumbers = !self.escapeNumbers; });
1233 options: function () {
1234 function xpath(arg) util.makeXPath(arg);
1236 options.add(["extendedhinttags", "eht"],
1237 "XPath or CSS selector strings of hintable elements for extended hint modes",
1240 "[asOTvVWy]": ["a[href]", "area[href]", "img[src]", "iframe[src]"],
1242 "[F]": ["body", "code", "div", "html", "p", "pre", "span"],
1243 "[S]": ["input:not([type=hidden])", "textarea", "button", "select"]
1247 getKey: function (val, default_)
1248 let (res = array.nth(this.value, function (re) let (match = re.exec(val)) match && match[0] == val, 0))
1249 res ? res.matcher : default_,
1250 setter: function (vals) {
1251 for (let value in values(vals))
1252 value.matcher = util.compileMatcher(Option.splitList(value.result));
1255 validator: util.validateMatcher
1258 options.add(["hinttags", "ht"],
1259 "XPath or CSS selector strings of hintable elements for Hints mode",
1260 "stringlist", "input:not([type=hidden]),a[href],area,iframe,textarea,button,select," +
1261 "[onclick],[onmouseover],[onmousedown],[onmouseup],[oncommand]," +
1262 "[tabindex],[role=link],[role=button],[contenteditable=true]",
1264 setter: function (values) {
1265 this.matcher = util.compileMatcher(values);
1268 validator: util.validateMatcher
1271 options.add(["hintkeys", "hk"],
1272 "The keys used to label and select hints",
1273 "string", "0123456789",
1276 "0123456789": "Numbers",
1277 "asdfg;lkjh": "Home Row"
1279 validator: function (value) {
1280 let values = events.fromString(value).map(events.closure.toString);
1281 return Option.validIf(array.uniq(values).length === values.length && values.length > 1,
1282 _("option.hintkeys.duplicate"));
1286 options.add(["hinttimeout", "hto"],
1287 "Timeout before automatically following a non-unique numerical hint",
1289 { validator: function (value) value >= 0 });
1291 options.add(["followhints", "fh"],
1292 "Define the conditions under which selected hints are followed",
1296 "0": "Follow the first hint as soon as typed text uniquely identifies it. Follow the selected hint on <Return>.",
1297 "1": "Follow the selected hint on <Return>.",
1301 options.add(["hintmatching", "hm"],
1302 "How hints are filtered",
1303 "stringlist", "contains",
1306 "contains": "The typed characters are split on whitespace. The resulting groups must all appear in the hint.",
1307 "custom": "Delegate to a custom function: dactyl.plugins.customHintMatcher(hintString)",
1308 "firstletters": "Behaves like wordstartswith, but all groups must match a sequence of words.",
1309 "wordstartswith": "The typed characters are split on whitespace. The resulting groups must all match the beginnings of words, in order.",
1310 "transliterated": UTF8("When true, special latin characters are translated to their ASCII equivalents (e.g., é ⇒ e)")
1312 validator: function (values) Option.validateCompleter.call(this, values) &&
1313 1 === values.reduce(function (acc, v) acc + (["contains", "custom", "firstletters", "wordstartswith"].indexOf(v) >= 0), 0)
1316 options.add(["wordseparators", "wsp"],
1317 "Regular expression defining which characters separate words when matching hints",
1318 "string", '[.,!?:;/"^$%&?()[\\]{}<>#*+|=~ _-]',
1319 { validator: function (value) RegExp(value) });
1321 options.add(["hintinputs", "hin"],
1322 "Which text is used to filter hints for input elements",
1323 "stringlist", "label,value",
1326 "value": "Match against the value of the input field",
1327 "label": "Match against the text of a label for the input field, if one can be found",
1328 "name": "Match against the name of the input field"
1334 // vim: set fdm=marker sw=4 ts=4 et: