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)))
304 if (elem.textContent || !elem.name)
307 let computedStyle = doc.defaultView.getComputedStyle(elem, null);
308 if (computedStyle.visibility != "visible" || computedStyle.display == "none")
313 let body = doc.body || doc.querySelector("body");
315 let fragment = DOM(<div highlight="hints"/>, doc).appendTo(body);
316 fragment.style.height; // Force application of binding.
317 let container = doc.getAnonymousElementByAttribute(fragment[0], "anonid", "hints") || fragment[0];
319 let baseNode = DOM(<span highlight="Hint" style="display: none;"/>, doc)[0];
321 let mode = this.hintMode;
322 let res = mode.matcher(doc);
324 let start = this.pageHints.length;
326 for (let elem in res)
327 if (isVisible(elem) && (!mode.filter || mode.filter(elem)))
330 rect: elem.getClientRects()[0] || elem.getBoundingClientRect(),
335 for (let hint in values(_hints)) {
336 let { elem, rect } = hint;
338 if (elem.hasAttributeNS(NS, "hint"))
339 [hint.text, hint.showText] = [elem.getAttributeNS(NS, "hint"), true];
340 else if (isinstance(elem, [HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement]))
341 [hint.text, hint.showText] = hints.getInputHint(elem, doc);
342 else if (elem.firstElementChild instanceof HTMLImageElement && /^\s*$/.test(elem.textContent))
343 [hint.text, hint.showText] = [elem.firstElementChild.alt || elem.firstElementChild.title, true];
345 hint.text = elem.textContent.toLowerCase();
347 hint.span = baseNode.cloneNode(false);
349 let leftPos = Math.max((rect.left + offsetX), offsetX);
350 let topPos = Math.max((rect.top + offsetY), offsetY);
352 if (elem instanceof HTMLAreaElement)
353 [leftPos, topPos] = this.getAreaOffset(elem, leftPos, topPos);
355 hint.span.setAttribute("style", ["display: none; left:", leftPos, "px; top:", topPos, "px"].join(""));
356 container.appendChild(hint.span);
358 this.pageHints.push(hint);
361 this.docs.push({ doc: doc, start: start, end: this.pageHints.length - 1 });
364 Array.forEach(win.frames, function (f) {
365 if (isVisible(f.frameElement)) {
366 let rect = f.frameElement.getBoundingClientRect();
368 left: Math.max(offsets.left - rect.left, 0),
369 right: Math.max(rect.right - offsets.right, 0),
370 top: Math.max(offsets.top - rect.top, 0),
371 bottom: Math.max(rect.bottom - offsets.bottom, 0)
382 * Will update the filter on displayed hints and follow the final hint if
385 * @param {Event} event The keypress event.
387 onChange: function onChange(event) {
388 this.prevInput = "text";
393 this.hintString = commandline.command;
394 this.updateStatusline();
396 if (this.validHints.length == 1)
401 * Handle a hints mode event.
403 * @param {Event} event The event to handle.
405 onKeyPress: function onKeyPress(eventList) {
406 const KILL = false, PASS = true;
407 let key = DOM.Event.stringify(eventList[0]);
411 if (!this.escapeNumbers && this.isHintKey(key)) {
412 this.prevInput = "number";
414 let oldHintNumber = this.hintNumber;
415 if (this.usedTabKey) {
417 this.usedTabKey = false;
419 this.hintNumber = this.hintNumber * this.hintKeys.length +
420 this.hintKeys.indexOf(key);
422 this.updateStatusline();
424 if (this.docs.length)
425 this.updateValidNumbers();
431 this.showActiveHint(this.hintNumber, oldHintNumber || 1);
433 dactyl.assert(this.hintNumber != 0);
442 onResize: function onResize() {
444 this.generate(this.top);
448 _onResize: function _onResize() {
450 hints.resizeTimer.tell();
456 * Called when there are one or zero hints in order to possibly activate it
457 * and, if activated, to clean up the rest of the hinting system.
459 * @param {boolean} followFirst Whether to force the following of the first
460 * link (when 'followhints' is 1 or 2)
463 process: function _processHints(followFirst) {
464 dactyl.assert(this.validHints.length > 0);
466 // This "followhints" option is *too* confusing. For me, and
467 // presumably for users, too. --Kris
468 if (options["followhints"] > 0 && !followFirst)
469 return; // no return hit; don't examine uniqueness
472 let firstHref = this.validHints[0].elem.getAttribute("href") || null;
474 if (this.validHints.some(function (h) h.elem.getAttribute("href") != firstHref))
477 else if (this.validHints.length > 1)
481 let timeout = followFirst || events.feedingKeys ? 0 : 500;
482 let activeIndex = (this.hintNumber ? this.hintNumber - 1 : 0);
483 let elem = this.validHints[activeIndex].elem;
489 this.removeHints(timeout);
493 let hinted = n || this.validHints.some(function (h) h.elem === elem);
495 hints.setClass(elem, null);
497 hints.setClass(elem, n % 2);
499 hints.setClass(elem, this.validHints[Math.max(0, this.hintNumber - 1)].elem === elem);
502 this.timeout(next, 50);
505 mappings.pushCommand();
506 if (!this.continue) {
509 modes.push(modes.IGNORE, modes.HINTS);
512 dactyl.trapErrors("action", this.hintMode,
513 elem, elem.href || elem.src || "",
514 this.extendedhintCount, top);
515 mappings.popCommand();
517 this.timeout(function () {
518 if (modes.main == modes.IGNORE && !this.continue)
520 commandline.lastEcho = null; // Hack.
521 if (this.continue && this.top)
527 * Remove all hints from the document, and reset the completions.
529 * Lingers on the active hint briefly to confirm the selection to the user.
531 * @param {number} timeout The number of milliseconds before the active
534 removeHints: function _removeHints(timeout) {
535 for (let { doc, start, end } in values(this.docs)) {
536 DOM(doc.documentElement).highlight.remove("Hinting");
537 // Goddamn stupid fucking Gecko 1.x security manager bullshit.
538 try { delete doc.dactylLabels; } catch (e) { doc.dactylLabels = undefined; }
540 for (let elem in DOM.XPath("//*[@dactyl:highlight='hints']", doc))
541 elem.parentNode.removeChild(elem);
542 for (let i in util.range(start, end + 1)) {
543 this.pageHints[i].ambiguous = false;
544 this.pageHints[i].valid = false;
547 styles.system.remove("hint-positions");
552 reset: function reset() {
554 this.validHints = [];
558 _reset: function _reset() {
559 if (!this.usedTabKey)
561 if (this.continue && this.validHints.length <= 1) {
562 this.hintString = "";
563 commandline.widgets.command = this.hintString;
566 this.updateStatusline();
570 * Display the hints in pageHints that are still valid.
572 show: function _show() {
574 let validHint = hints.hintMatcher(this.hintString.toLowerCase());
575 let activeHint = this.hintNumber || 1;
576 this.validHints = [];
578 for (let { doc, start, end } in values(this.docs)) {
579 DOM(doc.documentElement).highlight.add("Hinting");
580 let [offsetX, offsetY] = this.getContainerOffsets(doc);
583 for (let i in (util.interruptibleRange(start, end + 1, 500))) {
584 let hint = this.pageHints[i];
586 hint.valid = validHint(hint.text);
590 if (hint.text == "" && hint.elem.firstChild && hint.elem.firstChild instanceof HTMLImageElement) {
592 let rect = hint.elem.firstChild.getBoundingClientRect();
596 hint.imgSpan = DOM(<span highlight="Hint" dactyl:hl="HintImage" xmlns:dactyl={NS}/>, doc).css({
598 left: (rect.left + offsetX) + "px",
599 top: (rect.top + offsetY) + "px",
600 width: (rect.right - rect.left) + "px",
601 height: (rect.bottom - rect.top) + "px"
602 }).appendTo(hint.span.parentNode)[0];
606 let str = this.getHintString(hintnum);
608 if (hint.elem instanceof HTMLInputElement)
609 if (hint.elem.type === "radio")
610 text.push(UTF8(hint.elem.checked ? "⊙" : "○"));
611 else if (hint.elem.type === "checkbox")
612 text.push(UTF8(hint.elem.checked ? "☑" : "☐"));
613 if (hint.showText && !/^\s*$/.test(hint.text))
614 text.push(hint.text.substr(0, 50));
616 hint.span.setAttribute("text", str + (text.length ? ": " + text.join(" ") : ""));
617 hint.span.setAttribute("number", str);
619 hint.imgSpan.setAttribute("number", str);
621 hint.active = activeHint == hintnum;
623 this.validHints.push(hint);
628 let base = this.hintKeys.length;
629 for (let [i, hint] in Iterator(this.validHints))
630 hint.ambiguous = (i + 1) * base <= this.validHints.length;
632 if (options["usermode"]) {
634 for (let hint in values(this.pageHints)) {
635 let selector = highlight.selector("Hint") + "[number=" + hint.span.getAttribute("number").quote() + "]";
636 let imgSpan = "[dactyl|hl=HintImage]";
637 css.push(selector + ":not(" + imgSpan + ") { " + hint.span.style.cssText + " }");
639 css.push(selector + imgSpan + " { " + hint.span.style.cssText + " }");
641 styles.system.add("hint-positions", "*", css.join("\n"));
648 * Update the activeHint.
650 * By default highlights it green instead of yellow.
652 * @param {number} newId The hint to make active.
653 * @param {number} oldId The currently active hint.
655 showActiveHint: function _showActiveHint(newId, oldId) {
656 let oldHint = this.validHints[oldId - 1];
658 oldHint.active = false;
660 let newHint = this.validHints[newId - 1];
662 newHint.active = true;
665 backspace: function () {
667 if (this.prevInput !== "number")
670 if (this.hintNumber > 0 && !this.usedTabKey) {
671 this.hintNumber = Math.floor(this.hintNumber / this.hintKeys.length);
672 if (this.hintNumber == 0)
673 this.prevInput = "text";
677 this.usedTabKey = false;
684 updateValidNumbers: function updateValidNumbers(always) {
685 let string = this.getHintString(this.hintNumber);
686 for (let hint in values(this.validHints))
687 hint.valid = always || hint.span.getAttribute("number").indexOf(string) == 0;
690 tab: function tab(previous) {
692 this.usedTabKey = true;
693 if (this.hintNumber == 0)
696 let oldId = this.hintNumber;
698 if (++this.hintNumber > this.validHints.length)
702 if (--this.hintNumber < 1)
703 this.hintNumber = this.validHints.length;
706 this.updateValidNumbers(true);
707 this.showActiveHint(this.hintNumber, oldId);
708 this.updateStatusline();
711 update: function update(followFirst) {
713 this.updateStatusline();
715 if (this.docs.length == 0 && this.hintString.length > 0)
719 this.process(followFirst);
723 * Display the current status to the user.
725 updateStatusline: function _updateStatusline() {
726 statusline.inputBuffer = (this.escapeNumbers ? "\\" : "") +
727 (this.hintNumber ? this.getHintString(this.hintNumber) : "");
731 var Hints = Module("hints", {
732 init: function init() {
733 this.resizeTimer = Timer(100, 500, function () {
734 if (isinstance(modes.main, modes.HINTS))
735 modes.getStack(0).params.onResize();
738 let appContent = document.getElementById("appcontent");
740 events.listen(appContent, "scroll", this.resizeTimer.closure.tell, false);
742 const Mode = Hints.Mode;
743 Mode.prototype.__defineGetter__("matcher", function ()
744 options.get("extendedhinttags").getKey(this.name, options.get("hinttags").matcher));
747 this.addMode(";", "Focus hint", buffer.closure.focusElement);
748 this.addMode("?", "Show information for hint", function (elem) buffer.showElementInfo(elem));
749 this.addMode("s", "Save hint", function (elem) buffer.saveLink(elem, false));
750 this.addMode("f", "Focus frame", function (elem) dactyl.focus(elem.ownerDocument.defaultView));
751 this.addMode("F", "Focus frame or pseudo-frame", buffer.closure.focusElement, isScrollable);
752 this.addMode("o", "Follow hint", function (elem) buffer.followLink(elem, dactyl.CURRENT_TAB));
753 this.addMode("t", "Follow hint in a new tab", function (elem) buffer.followLink(elem, dactyl.NEW_TAB));
754 this.addMode("b", "Follow hint in a background tab", function (elem) buffer.followLink(elem, dactyl.NEW_BACKGROUND_TAB));
755 this.addMode("w", "Follow hint in a new window", function (elem) buffer.followLink(elem, dactyl.NEW_WINDOW));
756 this.addMode("O", "Generate an ‘:open URL’ prompt", function (elem, loc) CommandExMode().open("open " + loc));
757 this.addMode("T", "Generate a ‘:tabopen URL’ prompt", function (elem, loc) CommandExMode().open("tabopen " + loc));
758 this.addMode("W", "Generate a ‘:winopen URL’ prompt", function (elem, loc) CommandExMode().open("winopen " + loc));
759 this.addMode("a", "Add a bookmark", function (elem) bookmarks.addSearchKeyword(elem));
760 this.addMode("S", "Add a search keyword", function (elem) bookmarks.addSearchKeyword(elem));
761 this.addMode("v", "View hint source", function (elem, loc) buffer.viewSource(loc, false));
762 this.addMode("V", "View hint source in external editor", function (elem, loc) buffer.viewSource(loc, true));
763 this.addMode("y", "Yank hint location", function (elem, loc) editor.setRegister(null, loc, true));
764 this.addMode("Y", "Yank hint description", function (elem) editor.setRegister(null, elem.textContent || "", true));
765 this.addMode("A", "Yank hint anchor url", function (elem) {
766 let uri = elem.ownerDocument.documentURIObject.clone();
767 uri.ref = elem.id || elem.name;
768 dactyl.clipboardWrite(uri.spec, true);
770 this.addMode("c", "Open context menu", function (elem) DOM(elem).contextmenu());
771 this.addMode("i", "Show image", function (elem) dactyl.open(elem.src));
772 this.addMode("I", "Show image in a new tab", function (elem) dactyl.open(elem.src, dactyl.NEW_TAB));
774 function isScrollable(elem) isinstance(elem, [HTMLFrameElement, HTMLIFrameElement]) ||
775 Buffer.isScrollable(elem, 0, true) || Buffer.isScrollable(elem, 0, false);
778 hintSession: Modes.boundProperty(),
781 * Creates a new hints mode.
783 * @param {string} mode The letter that identifies this mode.
784 * @param {string} prompt The description to display to the user
786 * @param {function(Node)} action The function to be called with the
787 * element that matches.
788 * @param {function(Node):boolean} filter A function used to filter
789 * the returned node set.
790 * @param {[string]} tags A value to add to the default
791 * 'extendedhinttags' value for this mode.
794 addMode: function (mode, prompt, action, filter, tags) {
795 function toString(regexp) RegExp.prototype.toString.call(regexp);
798 let eht = options.get("extendedhinttags");
799 let update = eht.isDefault;
801 let value = eht.parse(Option.quote(util.regexp.escape(mode)) + ":" + tags.map(Option.quote))[0];
802 eht.defaultValue = eht.defaultValue.filter(function (re) toString(re) != toString(value))
809 this.modes[mode] = Hints.Mode(mode, UTF8(prompt), action, filter);
813 * Get a hint for "input", "textarea" and "select".
815 * Tries to use <label>s if possible but does not try to guess that a
816 * neighboring element might look like a label. Only called by
817 * {@link #_generate}.
819 * If it finds a hint it returns it, if the hint is not the caption of the
820 * element it will return showText=true.
822 * @param {Object} elem The element used to generate hint text.
823 * @param {Document} doc The containing document.
825 * @returns [text, showText]
827 getInputHint: function _getInputHint(elem, doc) {
828 // <input type="submit|button|reset"/> Always use the value
829 // <input type="radio|checkbox"/> Use the value if it is not numeric or label or name
830 // <input type="password"/> Never use the value, use label or name
831 // <input type="text|file"/> <textarea/> Use value if set or label or name
832 // <input type="image"/> Use the alt text if present (showText) or label or name
833 // <input type="hidden"/> Never gets here
834 // <select/> Use the text of the selected item or label or name
836 let type = elem.type;
838 if (DOM(elem).isInput)
839 return [elem.value, false];
841 for (let [, option] in Iterator(options["hintinputs"])) {
842 if (option == "value") {
843 if (elem instanceof HTMLSelectElement) {
844 if (elem.selectedIndex >= 0)
845 return [elem.item(elem.selectedIndex).text.toLowerCase(), false];
847 else if (type == "image") {
849 return [elem.alt.toLowerCase(), true];
851 else if (elem.value && type != "password") {
852 // radios and checkboxes often use internal ids as values - maybe make this an option too...
853 if (! ((type == "radio" || type == "checkbox") && !isNaN(elem.value)))
854 return [elem.value.toLowerCase(), (type == "radio" || type == "checkbox")];
857 else if (option == "label") {
859 let label = elem.ownerDocument.dactylLabels[elem.id];
861 return [label.textContent.toLowerCase(), true];
864 else if (option == "name")
865 return [elem.name.toLowerCase(), true];
873 * Get the hintMatcher according to user preference.
875 * @param {string} hintString The currently typed hint.
876 * @returns {hintMatcher}
878 hintMatcher: function _hintMatcher(hintString) { //{{{
880 * Divide a string by a regular expression.
882 * @param {RegExp|string} pat The pattern to split on.
883 * @param {string} str The string to split.
884 * @returns {Array(string)} The lowercased splits of the splitting.
886 function tokenize(pat, str) str.split(pat).map(String.toLowerCase);
889 * Get a hint matcher for hintmatching=contains
891 * The hintMatcher expects the user input to be space delimited and it
892 * returns true if each set of characters typed can be found, in any
893 * order, in the link.
895 * @param {string} hintString The string typed by the user.
896 * @returns {function(String):boolean} A function that takes the text
897 * of a hint and returns true if all the (space-delimited) sets of
898 * characters typed by the user can be found in it.
900 function containsMatcher(hintString) { //{{{
901 let tokens = tokenize(/\s+/, hintString);
902 return function (linkText) {
903 linkText = linkText.toLowerCase();
904 return tokens.every(function (token) indexOf(linkText, token) >= 0);
909 * Get a hintMatcher for hintmatching=firstletters|wordstartswith
911 * The hintMatcher will look for any division of the user input that
912 * would match the first letters of words. It will always only match
915 * @param {string} hintString The string typed by the user.
916 * @param {boolean} allowWordOverleaping Whether to allow non-contiguous
918 * @returns {function(String):boolean} A function that will filter only
919 * hints that match as above.
921 function wordStartsWithMatcher(hintString, allowWordOverleaping) { //{{{
922 let hintStrings = tokenize(/\s+/, hintString);
923 let wordSplitRegexp = util.regexp(options["wordseparators"]);
926 * Match a set of characters to the start of words.
928 * What the **** does this do? --Kris
929 * This function matches hintStrings like 'hekho' to links
930 * like 'Hey Kris, how are you?' -> [HE]y [K]ris [HO]w are you
933 * @param {string} chars The characters to match.
934 * @param {Array(string)} words The words to match them against.
935 * @param {boolean} allowWordOverleaping Whether words may be
936 * skipped during matching.
937 * @returns {boolean} Whether a match can be found.
939 function charsAtBeginningOfWords(chars, words, allowWordOverleaping) {
940 function charMatches(charIdx, chars, wordIdx, words, inWordIdx, allowWordOverleaping) {
941 let matches = (chars[charIdx] == words[wordIdx][inWordIdx]);
942 if ((matches == false && allowWordOverleaping) || words[wordIdx].length == 0) {
943 let nextWordIdx = wordIdx + 1;
944 if (nextWordIdx == words.length)
947 return charMatches(charIdx, chars, nextWordIdx, words, 0, allowWordOverleaping);
951 let nextCharIdx = charIdx + 1;
952 if (nextCharIdx == chars.length)
955 let nextWordIdx = wordIdx + 1;
956 let beyondLastWord = (nextWordIdx == words.length);
957 let charMatched = false;
958 if (beyondLastWord == false)
959 charMatched = charMatches(nextCharIdx, chars, nextWordIdx, words, 0, allowWordOverleaping);
964 if (charMatched == false || beyondLastWord == true) {
965 let nextInWordIdx = inWordIdx + 1;
966 if (nextInWordIdx == words[wordIdx].length)
969 return charMatches(nextCharIdx, chars, wordIdx, words, nextInWordIdx, allowWordOverleaping);
976 return charMatches(0, chars, 0, words, 0, allowWordOverleaping);
980 * Check whether the array of strings all exist at the start of the
983 * i.e. ['ro', 'e'] would match ['rollover', 'effect']
985 * The matches must be in order, and, if allowWordOverleaping is
988 * @param {Array(string)} strings The strings to search for.
989 * @param {Array(string)} words The words to search in.
990 * @param {boolean} allowWordOverleaping Whether matches may be
992 * @returns {boolean} Whether all the strings matched.
994 function stringsAtBeginningOfWords(strings, words, allowWordOverleaping) {
996 for (let [, word] in Iterator(words)) {
997 if (word.length == 0)
1000 let str = strings[strIdx];
1001 if (str.length == 0 || indexOf(word, str) == 0)
1003 else if (!allowWordOverleaping)
1006 if (strIdx == strings.length)
1010 for (; strIdx < strings.length; strIdx++) {
1011 if (strings[strIdx].length != 0)
1017 return function (linkText) {
1018 if (hintStrings.length == 1 && hintStrings[0].length == 0)
1021 let words = tokenize(wordSplitRegexp, linkText);
1022 if (hintStrings.length == 1)
1023 return charsAtBeginningOfWords(hintStrings[0], words, allowWordOverleaping);
1025 return stringsAtBeginningOfWords(hintStrings, words, allowWordOverleaping);
1029 let indexOf = String.indexOf;
1030 if (options.get("hintmatching").has("transliterated"))
1031 indexOf = Hints.closure.indexOf;
1033 switch (options["hintmatching"][0]) {
1034 case "contains" : return containsMatcher(hintString);
1035 case "wordstartswith": return wordStartsWithMatcher(hintString, true);
1036 case "firstletters" : return wordStartsWithMatcher(hintString, false);
1037 case "custom" : return dactyl.plugins.customHintMatcher(hintString);
1038 default : dactyl.echoerr(_("hints.noMatcher", hintMatching));
1043 open: function open(mode, opts) {
1044 this._extendedhintCount = opts.count;
1048 mappings.pushCommand();
1049 commandline.input(["Normal", mode], null, {
1050 autocomplete: false,
1051 completer: function (context) {
1052 context.compare = function () 0;
1053 context.completions = [[k, v.prompt] for ([k, v] in Iterator(hints.modes))];
1055 onCancel: mappings.closure.popCommand,
1056 onSubmit: function (arg) {
1058 hints.show(arg, opts);
1059 mappings.popCommand();
1061 onChange: function (arg) {
1062 if (Object.keys(hints.modes).some(function (m) m != arg && m.indexOf(arg) == 0))
1065 this.accepted = true;
1072 * Toggle the highlight of a hint.
1074 * @param {Object} elem The element to toggle.
1075 * @param {boolean} active Whether it is the currently active hint or not.
1077 setClass: function _setClass(elem, active) {
1078 if (elem.dactylHighlight == null)
1079 elem.dactylHighlight = elem.getAttributeNS(NS, "highlight") || "";
1081 let prefix = (elem.getAttributeNS(NS, "hl") || "") + " " + elem.dactylHighlight + " ";
1083 highlight.highlightNode(elem, prefix + "HintActive");
1084 else if (active != null)
1085 highlight.highlightNode(elem, prefix + "HintElem");
1087 highlight.highlightNode(elem, elem.dactylHighlight);
1088 // delete elem.dactylHighlight fails on Gecko 1.9. Issue #197
1089 elem.dactylHighlight = null;
1093 show: function show(mode, opts) {
1094 this.hintSession = HintSession(mode, opts);
1097 isVisible: function isVisible(elem, offScreen) {
1098 let rect = elem.getBoundingClientRect();
1099 if (!rect.width || !rect.height)
1100 if (!Array.some(elem.childNodes, function (elem) elem instanceof Element && DOM(elem).style.float != "none" && isVisible(elem)))
1103 let win = elem.ownerDocument.defaultView;
1104 if (offScreen && (rect.top + win.scrollY < 0 || rect.left + win.scrollX < 0 ||
1105 rect.bottom + win.scrollY > win.scrolMaxY + win.innerHeight ||
1106 rect.right + win.scrollX > win.scrolMaxX + win.innerWidth))
1109 if (!DOM(elem).isVisible)
1114 translitTable: Class.Memoize(function () {
1117 [0x00c0, 0x00c6, ["A"]], [0x00c7, 0x00c7, ["C"]],
1118 [0x00c8, 0x00cb, ["E"]], [0x00cc, 0x00cf, ["I"]],
1119 [0x00d1, 0x00d1, ["N"]], [0x00d2, 0x00d6, ["O"]],
1120 [0x00d8, 0x00d8, ["O"]], [0x00d9, 0x00dc, ["U"]],
1121 [0x00dd, 0x00dd, ["Y"]], [0x00e0, 0x00e6, ["a"]],
1122 [0x00e7, 0x00e7, ["c"]], [0x00e8, 0x00eb, ["e"]],
1123 [0x00ec, 0x00ef, ["i"]], [0x00f1, 0x00f1, ["n"]],
1124 [0x00f2, 0x00f6, ["o"]], [0x00f8, 0x00f8, ["o"]],
1125 [0x00f9, 0x00fc, ["u"]], [0x00fd, 0x00fd, ["y"]],
1126 [0x00ff, 0x00ff, ["y"]], [0x0100, 0x0105, ["A", "a"]],
1127 [0x0106, 0x010d, ["C", "c"]], [0x010e, 0x0111, ["D", "d"]],
1128 [0x0112, 0x011b, ["E", "e"]], [0x011c, 0x0123, ["G", "g"]],
1129 [0x0124, 0x0127, ["H", "h"]], [0x0128, 0x0130, ["I", "i"]],
1130 [0x0132, 0x0133, ["IJ", "ij"]], [0x0134, 0x0135, ["J", "j"]],
1131 [0x0136, 0x0136, ["K", "k"]], [0x0139, 0x0142, ["L", "l"]],
1132 [0x0143, 0x0148, ["N", "n"]], [0x0149, 0x0149, ["n"]],
1133 [0x014c, 0x0151, ["O", "o"]], [0x0152, 0x0153, ["OE", "oe"]],
1134 [0x0154, 0x0159, ["R", "r"]], [0x015a, 0x0161, ["S", "s"]],
1135 [0x0162, 0x0167, ["T", "t"]], [0x0168, 0x0173, ["U", "u"]],
1136 [0x0174, 0x0175, ["W", "w"]], [0x0176, 0x0178, ["Y", "y", "Y"]],
1137 [0x0179, 0x017e, ["Z", "z"]], [0x0180, 0x0183, ["b", "B", "B", "b"]],
1138 [0x0187, 0x0188, ["C", "c"]], [0x0189, 0x0189, ["D"]],
1139 [0x018a, 0x0192, ["D", "D", "d", "F", "f"]],
1140 [0x0193, 0x0194, ["G"]],
1141 [0x0197, 0x019b, ["I", "K", "k", "l", "l"]],
1142 [0x019d, 0x01a1, ["N", "n", "O", "O", "o"]],
1143 [0x01a4, 0x01a5, ["P", "p"]], [0x01ab, 0x01ab, ["t"]],
1144 [0x01ac, 0x01b0, ["T", "t", "T", "U", "u"]],
1145 [0x01b2, 0x01d2, ["V", "Y", "y", "Z", "z", "D", "L", "N", "A", "a",
1146 "I", "i", "O", "o"]],
1147 [0x01d3, 0x01dc, ["U", "u"]], [0x01de, 0x01e1, ["A", "a"]],
1148 [0x01e2, 0x01e3, ["AE", "ae"]],
1149 [0x01e4, 0x01ed, ["G", "g", "G", "g", "K", "k", "O", "o", "O", "o"]],
1150 [0x01f0, 0x01f5, ["j", "D", "G", "g"]],
1151 [0x01fa, 0x01fb, ["A", "a"]], [0x01fc, 0x01fd, ["AE", "ae"]],
1152 [0x01fe, 0x0217, ["O", "o", "A", "a", "A", "a", "E", "e", "E", "e",
1153 "I", "i", "I", "i", "O", "o", "O", "o", "R", "r", "R", "r", "U",
1155 [0x0253, 0x0257, ["b", "c", "d", "d"]],
1156 [0x0260, 0x0269, ["g", "h", "h", "i", "i"]],
1157 [0x026b, 0x0273, ["l", "l", "l", "l", "m", "n", "n"]],
1158 [0x027c, 0x028b, ["r", "r", "r", "r", "s", "t", "u", "u", "v"]],
1159 [0x0290, 0x0291, ["z"]], [0x029d, 0x02a0, ["j", "q"]],
1160 [0x1e00, 0x1e09, ["A", "a", "B", "b", "B", "b", "B", "b", "C", "c"]],
1161 [0x1e0a, 0x1e13, ["D", "d"]], [0x1e14, 0x1e1d, ["E", "e"]],
1162 [0x1e1e, 0x1e21, ["F", "f", "G", "g"]], [0x1e22, 0x1e2b, ["H", "h"]],
1163 [0x1e2c, 0x1e8f, ["I", "i", "I", "i", "K", "k", "K", "k", "K", "k",
1164 "L", "l", "L", "l", "L", "l", "L", "l", "M", "m", "M", "m", "M",
1165 "m", "N", "n", "N", "n", "N", "n", "N", "n", "O", "o", "O", "o",
1166 "O", "o", "O", "o", "P", "p", "P", "p", "R", "r", "R", "r", "R",
1167 "r", "R", "r", "S", "s", "S", "s", "S", "s", "S", "s", "S", "s",
1168 "T", "t", "T", "t", "T", "t", "T", "t", "U", "u", "U", "u", "U",
1169 "u", "U", "u", "U", "u", "V", "v", "V", "v", "W", "w", "W", "w",
1170 "W", "w", "W", "w", "W", "w", "X", "x", "X", "x", "Y", "y"]],
1171 [0x1e90, 0x1e9a, ["Z", "z", "Z", "z", "Z", "z", "h", "t", "w", "y", "a"]],
1172 [0x1ea0, 0x1eb7, ["A", "a"]], [0x1eb8, 0x1ec7, ["E", "e"]],
1173 [0x1ec8, 0x1ecb, ["I", "i"]], [0x1ecc, 0x1ee3, ["O", "o"]],
1174 [0x1ee4, 0x1ef1, ["U", "u"]], [0x1ef2, 0x1ef9, ["Y", "y"]],
1175 [0x2071, 0x2071, ["i"]], [0x207f, 0x207f, ["n"]],
1176 [0x249c, 0x24b5, "a"], [0x24b6, 0x24cf, "A"],
1177 [0x24d0, 0x24e9, "a"],
1178 [0xfb00, 0xfb06, ["ff", "fi", "fl", "ffi", "ffl", "st", "st"]],
1179 [0xff21, 0xff3a, "A"], [0xff41, 0xff5a, "a"]
1180 ].forEach(function ([start, stop, val]) {
1181 if (typeof val != "string")
1182 for (let i = start; i <= stop; i++)
1183 table[String.fromCharCode(i)] = val[(i - start) % val.length];
1185 let n = val.charCodeAt(0);
1186 for (let i = start; i <= stop; i++)
1187 table[String.fromCharCode(i)] = String.fromCharCode(n + i - start);
1192 indexOf: function indexOf(dest, src) {
1193 let table = this.translitTable;
1194 var end = dest.length - src.length;
1195 if (src.length == 0)
1198 for (var i = 0; i <= end; i++) {
1200 for (var k = 0; k < src.length;) {
1203 for (var l = 0; l < s.length; l++, k++) {
1206 if (k == src.length - 1)
1214 Mode: Struct("HintMode", "name", "prompt", "action", "filter")
1217 modes: function initModes() {
1218 initModes.require("commandline");
1219 modes.addMode("HINTS", {
1221 description: "Active when selecting elements with hints",
1222 bases: [modes.COMMAND_LINE],
1227 mappings: function () {
1228 let bind = function bind(names, description, action, params)
1229 mappings.add(config.browserModes, names, description,
1234 function () { hints.show("o"); });
1237 "Start Hints mode, but open link in a new tab",
1238 function () { hints.show(options.get("activate").has("links") ? "t" : "b"); });
1241 "Start an extended hints mode",
1242 function ({ count }) { hints.open(";", { count: count }); },
1246 "Start an extended hints mode and stay there until <Esc> is pressed",
1247 function ({ count }) { hints.open("g;", { continue: true, count: count }); },
1250 let bind = function bind(names, description, action, params)
1251 mappings.add([modes.HINTS], names, description,
1255 "Follow the selected hint",
1256 function ({ self }) { self.update(true); });
1259 "Focus the next matching hint",
1260 function ({ self }) { self.tab(false); });
1263 "Focus the previous matching hint",
1264 function ({ self }) { self.tab(true); });
1266 bind(["<BS>", "<C-h>"],
1267 "Delete the previous character",
1268 function ({ self }) self.backspace());
1271 "Toggle hint filtering",
1272 function ({ self }) { self.escapeNumbers = !self.escapeNumbers; });
1274 options: function () {
1275 options.add(["extendedhinttags", "eht"],
1276 "XPath or CSS selector strings of hintable elements for extended hint modes",
1278 // Make sure to update the docs when you change this.
1280 "[asOTvVWy]": [":-moz-any-link", "area[href]", "img[src]", "iframe[src]"],
1281 "[A]": ["[id]", "a[name]"],
1283 "[F]": ["body", "code", "div", "html", "p", "pre", "span"],
1284 "[S]": ["input:not([type=hidden])", "textarea", "button", "select"]
1288 getKey: function (val, default_)
1289 let (res = array.nth(this.value, function (re) let (match = re.exec(val)) match && match[0] == val, 0))
1290 res ? res.matcher : default_,
1291 parse: function parse(val) {
1292 let vals = parse.supercall(this, val);
1293 for (let value in values(vals))
1294 value.matcher = DOM.compileMatcher(Option.splitList(value.result));
1297 testValues: function testValues(vals, validator) vals.every(function (re) Option.splitList(re).every(validator)),
1298 validator: DOM.validateMatcher
1301 options.add(["hinttags", "ht"],
1302 "XPath or CSS selector strings of hintable elements for Hints mode",
1303 // Make sure to update the docs when you change this.
1304 "stringlist", ":-moz-any-link,area,button,iframe,input:not([type=hidden]),select,textarea," +
1305 "[onclick],[onmouseover],[onmousedown],[onmouseup],[oncommand]," +
1306 "[tabindex],[role=link],[role=button],[contenteditable=true]",
1308 setter: function (values) {
1309 this.matcher = DOM.compileMatcher(values);
1312 validator: DOM.validateMatcher
1315 options.add(["hintkeys", "hk"],
1316 "The keys used to label and select hints",
1317 "string", "0123456789",
1320 "0123456789": "Numbers",
1321 "asdfg;lkjh": "Home Row"
1323 validator: function (value) {
1324 let values = DOM.Event.parse(value).map(DOM.Event.closure.stringify);
1325 return Option.validIf(array.uniq(values).length === values.length && values.length > 1,
1326 _("option.hintkeys.duplicate"));
1330 options.add(["hinttimeout", "hto"],
1331 "Timeout before automatically following a non-unique numerical hint",
1333 { validator: function (value) value >= 0 });
1335 options.add(["followhints", "fh"],
1336 "Define the conditions under which selected hints are followed",
1340 "0": "Follow the first hint as soon as typed text uniquely identifies it. Follow the selected hint on <Return>.",
1341 "1": "Follow the selected hint on <Return>.",
1345 options.add(["hintmatching", "hm"],
1346 "How hints are filtered",
1347 "stringlist", "contains",
1350 "contains": "The typed characters are split on whitespace. The resulting groups must all appear in the hint.",
1351 "custom": "Delegate to a custom function: dactyl.plugins.customHintMatcher(hintString)",
1352 "firstletters": "Behaves like wordstartswith, but all groups must match a sequence of words.",
1353 "wordstartswith": "The typed characters are split on whitespace. The resulting groups must all match the beginnings of words, in order.",
1354 "transliterated": UTF8("When true, special latin characters are translated to their ASCII equivalents (e.g., é ⇒ e)")
1356 validator: function (values) Option.validateCompleter.call(this, values) &&
1357 1 === values.reduce(function (acc, v) acc + (["contains", "custom", "firstletters", "wordstartswith"].indexOf(v) >= 0), 0)
1360 options.add(["wordseparators", "wsp"],
1361 "Regular expression defining which characters separate words when matching hints",
1362 "string", '[.,!?:;/"^$%&?()[\\]{}<>#*+|=~ _-]',
1363 { validator: function (value) RegExp(value) });
1365 options.add(["hintinputs", "hin"],
1366 "Which text is used to filter hints for input elements",
1367 "stringlist", "label,value",
1370 "value": "Match against the value of the input field",
1371 "label": "Match against the text of a label for the input field, if one can be found",
1372 "name": "Match against the name of the input field"
1378 // vim: set fdm=marker sw=4 ts=4 et: