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-2013 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 = {}) {
19 opts.window = modes.getStack(0).params.window;
21 this.hintMode = hints.modes[mode];
22 dactyl.assert(this.hintMode);
24 this.activeTimeout = null; // needed for hinttimeout > 0
25 this.continue = Boolean(opts.continue);
27 this.hintKeys = DOM.Event.parse(options["hintkeys"]).map(DOM.Event.closure.stringify);
29 this.hintString = opts.filter || "";
32 this.usedTabKey = false;
33 this.validHints = []; // store the indices of the "hints" array with valid elements
35 mappings.pushCommand();
38 this.top = opts.window || content;
39 this.top.addEventListener("resize", this.closure._onResize, true);
40 this.top.addEventListener("dactyl-commandupdate", this.closure._onResize, false, true);
47 if (this.validHints.length == 0) {
51 else if (this.validHints.length == 1 && !this.continue)
58 get active() this._active,
62 this.span.setAttribute("active", true);
64 this.span.removeAttribute("active");
66 hints.setClass(this.elem, this.valid ? val : null);
68 hints.setClass(this.imgSpan, this.valid ? val : null);
71 get ambiguous() this.span.hasAttribute("ambiguous"),
73 let meth = val ? "setAttribute" : "removeAttribute";
74 this.elem[meth]("ambiguous", "true");
75 this.span[meth]("ambiguous", "true");
77 this.imgSpan[meth]("ambiguous", "true");
80 get valid() this._valid,
84 this.span.style.display = (val ? "" : "none");
86 this.imgSpan.style.display = (val ? "" : "none");
87 this.active = this.active;
91 get mode() modes.HINTS,
93 get prompt() ["Question", UTF8(this.hintMode.prompt) + ": "],
95 leave: function leave(stack) {
96 leave.superapply(this, arguments);
99 mappings.popCommand();
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 = DOM(body).style;
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", () =>
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, elem => elem instanceof Element
302 && DOM(elem).style.float != "none"
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, [Ci.nsIDOMHTMLInputElement,
341 Ci.nsIDOMHTMLSelectElement,
342 Ci.nsIDOMHTMLTextAreaElement]))
343 [hint.text, hint.showText] = hints.getInputHint(elem, doc);
344 else if (elem.firstElementChild instanceof Ci.nsIDOMHTMLImageElement && /^\s*$/.test(elem.textContent))
345 [hint.text, hint.showText] = [elem.firstElementChild.alt || elem.firstElementChild.title, true];
347 hint.text = elem.textContent.toLowerCase();
349 hint.span = baseNode.cloneNode(false);
351 let leftPos = Math.max((rect.left + offsetX), offsetX);
352 let topPos = Math.max((rect.top + offsetY), offsetY);
354 if (elem instanceof Ci.nsIDOMHTMLAreaElement)
355 [leftPos, topPos] = this.getAreaOffset(elem, leftPos, topPos);
357 hint.span.setAttribute("style", ["display: none; left:", leftPos, "px; top:", topPos, "px"].join(""));
358 container.appendChild(hint.span);
360 this.pageHints.push(hint);
363 this.docs.push({ doc: doc, start: start, end: this.pageHints.length - 1 });
366 Array.forEach(win.frames, function (f) {
367 if (isVisible(f.frameElement)) {
368 let rect = f.frameElement.getBoundingClientRect();
370 left: Math.max(offsets.left - rect.left, 0),
371 right: Math.max(rect.right - offsets.right, 0),
372 top: Math.max(offsets.top - rect.top, 0),
373 bottom: Math.max(rect.bottom - offsets.bottom, 0)
384 * Will update the filter on displayed hints and follow the final hint if
387 * @param {Event} event The keypress event.
389 onChange: function onChange(event) {
390 this.prevInput = "text";
395 this.hintString = commandline.command;
396 this.updateStatusline();
398 if (this.validHints.length == 1)
403 * Handle a hints mode event.
405 * @param {Event} event The event to handle.
407 onKeyPress: function onKeyPress(eventList) {
408 const KILL = false, PASS = true;
409 let key = DOM.Event.stringify(eventList[0]);
413 if (!this.escapeNumbers && this.isHintKey(key)) {
414 this.prevInput = "number";
416 let oldHintNumber = this.hintNumber;
417 if (this.usedTabKey) {
419 this.usedTabKey = false;
421 this.hintNumber = this.hintNumber * this.hintKeys.length +
422 this.hintKeys.indexOf(key);
424 this.updateStatusline();
426 if (this.docs.length)
427 this.updateValidNumbers();
433 this.showActiveHint(this.hintNumber, oldHintNumber || 1);
435 dactyl.assert(this.hintNumber != 0);
444 onResize: function onResize() {
446 this.generate(this.top);
450 _onResize: function _onResize() {
452 hints.resizeTimer.tell();
458 * Called when there are one or zero hints in order to possibly activate it
459 * and, if activated, to clean up the rest of the hinting system.
461 * @param {boolean} followFirst Whether to force the following of the first
462 * link (when 'followhints' is 1 or 2)
465 process: function _processHints(followFirst) {
466 dactyl.assert(this.validHints.length > 0);
468 // This "followhints" option is *too* confusing. For me, and
469 // presumably for users, too. --Kris
470 if (options["followhints"] > 0 && !followFirst)
471 return; // no return hit; don't examine uniqueness
474 let firstHref = this.validHints[0].elem.getAttribute("href") || null;
476 if (this.validHints.some(h => h.elem.getAttribute("href") != firstHref))
479 else if (this.validHints.length > 1)
483 let timeout = followFirst || events.feedingKeys ? 0 : 500;
484 let activeIndex = (this.hintNumber ? this.hintNumber - 1 : 0);
485 let elem = this.validHints[activeIndex].elem;
491 this.removeHints(timeout);
495 if (Cu.isDeadWrapper && Cu.isDeadWrapper(elem))
496 // Hint document has been unloaded.
499 let hinted = n || this.validHints.some(h => h.elem === elem);
501 hints.setClass(elem, null);
503 hints.setClass(elem, n % 2);
505 hints.setClass(elem, this.validHints[Math.max(0, this.hintNumber - 1)].elem === elem);
508 this.timeout(next, 50);
511 mappings.pushCommand();
512 if (!this.continue) {
515 modes.push(modes.IGNORE, modes.HINTS);
518 dactyl.trapErrors("action", this.hintMode,
519 elem, elem.href || elem.src || "",
520 this.extendedhintCount, top);
521 mappings.popCommand();
523 this.timeout(function () {
524 if (modes.main == modes.IGNORE && !this.continue)
526 commandline.lastEcho = null; // Hack.
527 if (this.continue && this.top)
533 * Remove all hints from the document, and reset the completions.
535 * Lingers on the active hint briefly to confirm the selection to the user.
537 * @param {number} timeout The number of milliseconds before the active
540 removeHints: function _removeHints(timeout) {
541 for (let { doc, start, end } in values(this.docs)) {
542 DOM(doc.documentElement).highlight.remove("Hinting");
543 // Goddamn stupid fucking Gecko 1.x security manager bullshit.
544 try { delete doc.dactylLabels; } catch (e) { doc.dactylLabels = undefined; }
546 for (let elem in DOM.XPath("//*[@dactyl:highlight='hints']", doc))
547 elem.parentNode.removeChild(elem);
548 for (let i in util.range(start, end + 1)) {
549 this.pageHints[i].ambiguous = false;
550 this.pageHints[i].valid = false;
553 styles.system.remove("hint-positions");
558 reset: function reset() {
560 this.validHints = [];
564 _reset: function _reset() {
565 if (!this.usedTabKey)
567 if (this.continue && this.validHints.length <= 1) {
568 this.hintString = "";
569 commandline.widgets.command = this.hintString;
572 this.updateStatusline();
576 * Display the hints in pageHints that are still valid.
579 show: function _show() {
580 let count = ++this.showCount;
582 let validHint = hints.hintMatcher(this.hintString.toLowerCase());
583 let activeHint = this.hintNumber || 1;
584 this.validHints = [];
586 for (let { doc, start, end } in values(this.docs)) {
587 DOM(doc.documentElement).highlight.add("Hinting");
588 let [offsetX, offsetY] = this.getContainerOffsets(doc);
591 for (let i in (util.interruptibleRange(start, end + 1, 500))) {
592 if (this.showCount != count)
595 let hint = this.pageHints[i];
597 hint.valid = validHint(hint.text);
601 if (hint.text == "" && hint.elem.firstChild && hint.elem.firstChild instanceof Ci.nsIDOMHTMLImageElement) {
603 let rect = hint.elem.firstChild.getBoundingClientRect();
607 hint.imgSpan = DOM(["span", { highlight: "Hint", "dactyl:hl": "HintImage" }], doc).css({
609 left: (rect.left + offsetX) + "px",
610 top: (rect.top + offsetY) + "px",
611 width: (rect.right - rect.left) + "px",
612 height: (rect.bottom - rect.top) + "px"
613 }).appendTo(hint.span.parentNode)[0];
617 let str = this.getHintString(hintnum);
619 if (hint.elem instanceof Ci.nsIDOMHTMLInputElement)
620 if (hint.elem.type === "radio")
621 text.push(UTF8(hint.elem.checked ? "⊙" : "○"));
622 else if (hint.elem.type === "checkbox")
623 text.push(UTF8(hint.elem.checked ? "☑" : "☐"));
624 if (hint.showText && !/^\s*$/.test(hint.text))
625 text.push(hint.text.substr(0, 50));
627 hint.span.setAttribute("text", str + (text.length ? ": " + text.join(" ") : ""));
628 hint.span.setAttribute("number", str);
630 hint.imgSpan.setAttribute("number", str);
632 hint.active = activeHint == hintnum;
634 this.validHints.push(hint);
639 let base = this.hintKeys.length;
640 for (let [i, hint] in Iterator(this.validHints))
641 hint.ambiguous = (i + 1) * base <= this.validHints.length;
643 if (options["usermode"]) {
645 for (let hint in values(this.pageHints)) {
646 let selector = highlight.selector("Hint") + "[number=" + hint.span.getAttribute("number").quote() + "]";
647 let imgSpan = "[dactyl|hl=HintImage]";
648 css.push(selector + ":not(" + imgSpan + ") { " + hint.span.style.cssText + " }");
650 css.push(selector + imgSpan + " { " + hint.span.style.cssText + " }");
652 styles.system.add("hint-positions", "*", css.join("\n"));
659 * Update the activeHint.
661 * By default highlights it green instead of yellow.
663 * @param {number} newId The hint to make active.
664 * @param {number} oldId The currently active hint.
666 showActiveHint: function _showActiveHint(newId, oldId) {
667 let oldHint = this.validHints[oldId - 1];
669 oldHint.active = false;
671 let newHint = this.validHints[newId - 1];
673 newHint.active = true;
676 backspace: function () {
678 if (this.prevInput !== "number")
681 if (this.hintNumber > 0 && !this.usedTabKey) {
682 this.hintNumber = Math.floor(this.hintNumber / this.hintKeys.length);
683 if (this.hintNumber == 0)
684 this.prevInput = "text";
688 this.usedTabKey = false;
695 updateValidNumbers: function updateValidNumbers(always) {
696 let string = this.getHintString(this.hintNumber);
697 for (let hint in values(this.validHints))
698 hint.valid = always || hint.span.getAttribute("number").indexOf(string) == 0;
701 tab: function tab(previous) {
703 this.usedTabKey = true;
704 if (this.hintNumber == 0)
707 let oldId = this.hintNumber;
709 if (++this.hintNumber > this.validHints.length)
713 if (--this.hintNumber < 1)
714 this.hintNumber = this.validHints.length;
717 this.updateValidNumbers(true);
718 this.showActiveHint(this.hintNumber, oldId);
719 this.updateStatusline();
722 update: function update(followFirst) {
724 this.updateStatusline();
726 if (this.docs.length == 0 && this.hintString.length > 0)
730 this.process(followFirst);
734 * Display the current status to the user.
736 updateStatusline: function _updateStatusline() {
737 statusline.inputBuffer = (this.escapeNumbers ? "\\" : "") +
738 (this.hintNumber ? this.getHintString(this.hintNumber) : "");
742 var Hints = Module("hints", {
743 init: function init() {
744 this.resizeTimer = Timer(100, 500, function () {
745 if (isinstance(modes.main, modes.HINTS))
746 modes.getStack(0).params.onResize();
749 let appContent = document.getElementById("appcontent");
751 events.listen(appContent, "scroll", this.resizeTimer.closure.tell, false);
753 const Mode = Hints.Mode;
754 Mode.prototype.__defineGetter__("matcher", function ()
755 options.get("extendedhinttags").getKey(this.name, options.get("hinttags").matcher));
758 this.addMode(";", "Focus hint", buffer.closure.focusElement);
759 this.addMode("?", "Show information for hint", elem => buffer.showElementInfo(elem));
760 // TODO: allow for ! override to overwrite existing paths -- where? --djk
761 this.addMode("s", "Save hint", elem => buffer.saveLink(elem, false));
762 this.addMode("f", "Focus frame", elem => dactyl.focus(elem.ownerDocument.defaultView));
763 this.addMode("F", "Focus frame or pseudo-frame", buffer.closure.focusElement, isScrollable);
764 this.addMode("o", "Follow hint", elem => buffer.followLink(elem, dactyl.CURRENT_TAB));
765 this.addMode("t", "Follow hint in a new tab", elem => buffer.followLink(elem, dactyl.NEW_TAB));
766 this.addMode("b", "Follow hint in a background tab", elem => buffer.followLink(elem, dactyl.NEW_BACKGROUND_TAB));
767 this.addMode("w", "Follow hint in a new window", elem => buffer.followLink(elem, dactyl.NEW_WINDOW));
768 this.addMode("O", "Generate an ‘:open URL’ prompt", (elem, loc) => CommandExMode().open("open " + loc));
769 this.addMode("T", "Generate a ‘:tabopen URL’ prompt", (elem, loc) => CommandExMode().open("tabopen " + loc));
770 this.addMode("W", "Generate a ‘:winopen URL’ prompt", (elem, loc) => CommandExMode().open("winopen " + loc));
771 this.addMode("a", "Add a bookmark", elem => bookmarks.addSearchKeyword(elem));
772 this.addMode("S", "Add a search keyword", elem => bookmarks.addSearchKeyword(elem));
773 this.addMode("v", "View hint source", (elem, loc) => buffer.viewSource(loc, false));
774 this.addMode("V", "View hint source in external editor", (elem, loc) => buffer.viewSource(loc, true));
775 this.addMode("y", "Yank hint location", (elem, loc) => editor.setRegister(null, loc, true));
776 this.addMode("Y", "Yank hint description", elem => editor.setRegister(null, elem.textContent || "", true));
777 this.addMode("A", "Yank hint anchor url", function (elem) {
778 let uri = elem.ownerDocument.documentURIObject.clone();
779 uri.ref = elem.id || elem.name;
780 dactyl.clipboardWrite(uri.spec, true);
782 this.addMode("c", "Open context menu", elem => DOM(elem).contextmenu());
783 this.addMode("i", "Show image", elem => dactyl.open(elem.src));
784 this.addMode("I", "Show image in a new tab", elem => dactyl.open(elem.src, dactyl.NEW_TAB));
786 function isScrollable(elem) isinstance(elem, [Ci.nsIDOMHTMLFrameElement,
787 Ci.nsIDOMHTMLIFrameElement]) ||
788 Buffer.isScrollable(elem, 0, true) || Buffer.isScrollable(elem, 0, false);
791 hintSession: Modes.boundProperty(),
794 * Creates a new hints mode.
796 * @param {string} mode The letter that identifies this mode.
797 * @param {string} prompt The description to display to the user
799 * @param {function(Node)} action The function to be called with the
800 * element that matches.
801 * @param {function(Node):boolean} filter A function used to filter
802 * the returned node set.
803 * @param {[string]} tags A value to add to the default
804 * 'extendedhinttags' value for this mode.
807 addMode: function (mode, prompt, action, filter, tags) {
808 function toString(regexp) RegExp.prototype.toString.call(regexp);
811 let eht = options.get("extendedhinttags");
812 let update = eht.isDefault;
814 let value = eht.parse(Option.quote(util.regexp.escape(mode)) + ":" + tags.map(Option.quote))[0];
815 eht.defaultValue = eht.defaultValue.filter(re => toString(re) != toString(value))
822 this.modes[mode] = Hints.Mode(mode, UTF8(prompt), action, filter);
826 * Get a hint for "input", "textarea" and "select".
828 * Tries to use <label>s if possible but does not try to guess that a
829 * neighboring element might look like a label. Only called by
830 * {@link #_generate}.
832 * If it finds a hint it returns it, if the hint is not the caption of the
833 * element it will return showText=true.
835 * @param {Object} elem The element used to generate hint text.
836 * @param {Document} doc The containing document.
838 * @returns [text, showText]
840 getInputHint: function _getInputHint(elem, doc) {
841 // <input type="submit|button|reset"/> Always use the value
842 // <input type="radio|checkbox"/> Use the value if it is not numeric or label or name
843 // <input type="password"/> Never use the value, use label or name
844 // <input type="text|file"/> <textarea/> Use value if set or label or name
845 // <input type="image"/> Use the alt text if present (showText) or label or name
846 // <input type="hidden"/> Never gets here
847 // <select/> Use the text of the selected item or label or name
849 let type = elem.type;
851 if (DOM(elem).isInput)
852 return [elem.value, false];
854 for (let [, option] in Iterator(options["hintinputs"])) {
855 if (option == "value") {
856 if (elem instanceof Ci.nsIDOMHTMLSelectElement) {
857 if (elem.selectedIndex >= 0)
858 return [elem.item(elem.selectedIndex).text.toLowerCase(), false];
860 else if (type == "image") {
862 return [elem.alt.toLowerCase(), true];
864 else if (elem.value && type != "password") {
865 // radios and checkboxes often use internal ids as values - maybe make this an option too...
866 if (! ((type == "radio" || type == "checkbox") && !isNaN(elem.value)))
867 return [elem.value.toLowerCase(), (type == "radio" || type == "checkbox")];
870 else if (option == "label") {
872 let label = (elem.ownerDocument.dactylLabels || {})[elem.id];
875 return [label.textContent.toLowerCase(), true];
878 else if (option == "name")
879 return [elem.name.toLowerCase(), true];
887 * Get the hintMatcher according to user preference.
889 * @param {string} hintString The currently typed hint.
890 * @returns {hintMatcher}
892 hintMatcher: function _hintMatcher(hintString) { //{{{
894 * Divide a string by a regular expression.
896 * @param {RegExp|string} pat The pattern to split on.
897 * @param {string} str The string to split.
898 * @returns {Array(string)} The lowercased splits of the splitting.
900 function tokenize(pat, str) str.split(pat).map(String.toLowerCase);
903 * Get a hint matcher for hintmatching=contains
905 * The hintMatcher expects the user input to be space delimited and it
906 * returns true if each set of characters typed can be found, in any
907 * order, in the link.
909 * @param {string} hintString The string typed by the user.
910 * @returns {function(String):boolean} A function that takes the text
911 * of a hint and returns true if all the (space-delimited) sets of
912 * characters typed by the user can be found in it.
914 function containsMatcher(hintString) { //{{{
915 let tokens = tokenize(/\s+/, hintString);
916 return function (linkText) {
917 linkText = linkText.toLowerCase();
918 return tokens.every(token => indexOf(linkText, token) >= 0);
923 * Get a hintMatcher for hintmatching=firstletters|wordstartswith
925 * The hintMatcher will look for any division of the user input that
926 * would match the first letters of words. It will always only match
929 * @param {string} hintString The string typed by the user.
930 * @param {boolean} allowWordOverleaping Whether to allow non-contiguous
932 * @returns {function(String):boolean} A function that will filter only
933 * hints that match as above.
935 function wordStartsWithMatcher(hintString, allowWordOverleaping) { //{{{
936 let hintStrings = tokenize(/\s+/, hintString);
937 let wordSplitRegexp = util.regexp(options["wordseparators"]);
940 * Match a set of characters to the start of words.
942 * What the **** does this do? --Kris
943 * This function matches hintStrings like 'hekho' to links
944 * like 'Hey Kris, how are you?' -> [HE]y [K]ris [HO]w are you
947 * @param {string} chars The characters to match.
948 * @param {Array(string)} words The words to match them against.
949 * @param {boolean} allowWordOverleaping Whether words may be
950 * skipped during matching.
951 * @returns {boolean} Whether a match can be found.
953 function charsAtBeginningOfWords(chars, words, allowWordOverleaping) {
954 function charMatches(charIdx, chars, wordIdx, words, inWordIdx, allowWordOverleaping) {
955 let matches = (chars[charIdx] == words[wordIdx][inWordIdx]);
956 if ((matches == false && allowWordOverleaping) || words[wordIdx].length == 0) {
957 let nextWordIdx = wordIdx + 1;
958 if (nextWordIdx == words.length)
961 return charMatches(charIdx, chars, nextWordIdx, words, 0, allowWordOverleaping);
965 let nextCharIdx = charIdx + 1;
966 if (nextCharIdx == chars.length)
969 let nextWordIdx = wordIdx + 1;
970 let beyondLastWord = (nextWordIdx == words.length);
971 let charMatched = false;
972 if (beyondLastWord == false)
973 charMatched = charMatches(nextCharIdx, chars, nextWordIdx, words, 0, allowWordOverleaping);
978 if (charMatched == false || beyondLastWord == true) {
979 let nextInWordIdx = inWordIdx + 1;
980 if (nextInWordIdx == words[wordIdx].length)
983 return charMatches(nextCharIdx, chars, wordIdx, words, nextInWordIdx, allowWordOverleaping);
990 return charMatches(0, chars, 0, words, 0, allowWordOverleaping);
994 * Check whether the array of strings all exist at the start of the
997 * i.e. ['ro', 'e'] would match ['rollover', 'effect']
999 * The matches must be in order, and, if allowWordOverleaping is
1000 * false, contiguous.
1002 * @param {Array(string)} strings The strings to search for.
1003 * @param {Array(string)} words The words to search in.
1004 * @param {boolean} allowWordOverleaping Whether matches may be
1006 * @returns {boolean} Whether all the strings matched.
1008 function stringsAtBeginningOfWords(strings, words, allowWordOverleaping) {
1010 for (let [, word] in Iterator(words)) {
1011 if (word.length == 0)
1014 let str = strings[strIdx];
1015 if (str.length == 0 || indexOf(word, str) == 0)
1017 else if (!allowWordOverleaping)
1020 if (strIdx == strings.length)
1024 for (; strIdx < strings.length; strIdx++) {
1025 if (strings[strIdx].length != 0)
1031 return function (linkText) {
1032 if (hintStrings.length == 1 && hintStrings[0].length == 0)
1035 let words = tokenize(wordSplitRegexp, linkText);
1036 if (hintStrings.length == 1)
1037 return charsAtBeginningOfWords(hintStrings[0], words, allowWordOverleaping);
1039 return stringsAtBeginningOfWords(hintStrings, words, allowWordOverleaping);
1043 let indexOf = String.indexOf;
1044 if (options.get("hintmatching").has("transliterated"))
1045 indexOf = Hints.closure.indexOf;
1047 switch (options["hintmatching"][0]) {
1048 case "contains" : return containsMatcher(hintString);
1049 case "wordstartswith": return wordStartsWithMatcher(hintString, true);
1050 case "firstletters" : return wordStartsWithMatcher(hintString, false);
1051 case "custom" : return dactyl.plugins.customHintMatcher(hintString);
1052 default : dactyl.echoerr(_("hints.noMatcher", hintMatching));
1057 open: function open(mode, opts = {}) {
1058 this._extendedhintCount = opts.count;
1060 mappings.pushCommand();
1061 commandline.input(["Normal", mode], null, {
1062 autocomplete: false,
1063 completer: function (context) {
1064 context.compare = () => 0;
1065 context.completions = [[k, v.prompt] for ([k, v] in Iterator(hints.modes))];
1067 onCancel: mappings.closure.popCommand,
1068 onSubmit: function (arg) {
1070 hints.show(arg, opts);
1071 mappings.popCommand();
1073 onChange: function (arg) {
1074 if (Object.keys(hints.modes).some(m => m != arg && m.indexOf(arg) == 0))
1077 this.accepted = true;
1084 * Toggle the highlight of a hint.
1086 * @param {Object} elem The element to toggle.
1087 * @param {boolean} active Whether it is the currently active hint or not.
1089 setClass: function _setClass(elem, active) {
1090 if (elem.dactylHighlight == null)
1091 elem.dactylHighlight = elem.getAttributeNS(NS, "highlight") || "";
1093 let prefix = (elem.getAttributeNS(NS, "hl") || "") + " " + elem.dactylHighlight + " ";
1095 highlight.highlightNode(elem, prefix + "HintActive");
1096 else if (active != null)
1097 highlight.highlightNode(elem, prefix + "HintElem");
1099 highlight.highlightNode(elem, elem.dactylHighlight);
1100 // delete elem.dactylHighlight fails on Gecko 1.9. Issue #197
1101 elem.dactylHighlight = null;
1105 show: function show(mode, opts) {
1106 this.hintSession = HintSession(mode, opts);
1109 isVisible: function isVisible(elem, offScreen) {
1110 let rect = elem.getBoundingClientRect();
1111 if (!rect.width || !rect.height)
1112 if (!Array.some(elem.childNodes, elem => elem instanceof Element
1113 && DOM(elem).style.float != "none"
1114 && isVisible(elem)))
1117 let win = elem.ownerDocument.defaultView;
1118 if (offScreen && (rect.top + win.scrollY < 0 || rect.left + win.scrollX < 0 ||
1119 rect.bottom + win.scrollY > win.scrolMaxY + win.innerHeight ||
1120 rect.right + win.scrollX > win.scrolMaxX + win.innerWidth))
1123 if (!DOM(elem).isVisible)
1128 translitTable: Class.Memoize(function () {
1131 [0x00c0, 0x00c6, ["A"]], [0x00c7, 0x00c7, ["C"]],
1132 [0x00c8, 0x00cb, ["E"]], [0x00cc, 0x00cf, ["I"]],
1133 [0x00d1, 0x00d1, ["N"]], [0x00d2, 0x00d6, ["O"]],
1134 [0x00d8, 0x00d8, ["O"]], [0x00d9, 0x00dc, ["U"]],
1135 [0x00dd, 0x00dd, ["Y"]], [0x00e0, 0x00e6, ["a"]],
1136 [0x00e7, 0x00e7, ["c"]], [0x00e8, 0x00eb, ["e"]],
1137 [0x00ec, 0x00ef, ["i"]], [0x00f1, 0x00f1, ["n"]],
1138 [0x00f2, 0x00f6, ["o"]], [0x00f8, 0x00f8, ["o"]],
1139 [0x00f9, 0x00fc, ["u"]], [0x00fd, 0x00fd, ["y"]],
1140 [0x00ff, 0x00ff, ["y"]], [0x0100, 0x0105, ["A", "a"]],
1141 [0x0106, 0x010d, ["C", "c"]], [0x010e, 0x0111, ["D", "d"]],
1142 [0x0112, 0x011b, ["E", "e"]], [0x011c, 0x0123, ["G", "g"]],
1143 [0x0124, 0x0127, ["H", "h"]], [0x0128, 0x0130, ["I", "i"]],
1144 [0x0132, 0x0133, ["IJ", "ij"]], [0x0134, 0x0135, ["J", "j"]],
1145 [0x0136, 0x0136, ["K", "k"]], [0x0139, 0x0142, ["L", "l"]],
1146 [0x0143, 0x0148, ["N", "n"]], [0x0149, 0x0149, ["n"]],
1147 [0x014c, 0x0151, ["O", "o"]], [0x0152, 0x0153, ["OE", "oe"]],
1148 [0x0154, 0x0159, ["R", "r"]], [0x015a, 0x0161, ["S", "s"]],
1149 [0x0162, 0x0167, ["T", "t"]], [0x0168, 0x0173, ["U", "u"]],
1150 [0x0174, 0x0175, ["W", "w"]], [0x0176, 0x0178, ["Y", "y", "Y"]],
1151 [0x0179, 0x017e, ["Z", "z"]], [0x0180, 0x0183, ["b", "B", "B", "b"]],
1152 [0x0187, 0x0188, ["C", "c"]], [0x0189, 0x0189, ["D"]],
1153 [0x018a, 0x0192, ["D", "D", "d", "F", "f"]],
1154 [0x0193, 0x0194, ["G"]],
1155 [0x0197, 0x019b, ["I", "K", "k", "l", "l"]],
1156 [0x019d, 0x01a1, ["N", "n", "O", "O", "o"]],
1157 [0x01a4, 0x01a5, ["P", "p"]], [0x01ab, 0x01ab, ["t"]],
1158 [0x01ac, 0x01b0, ["T", "t", "T", "U", "u"]],
1159 [0x01b2, 0x01d2, ["V", "Y", "y", "Z", "z", "D", "L", "N", "A", "a",
1160 "I", "i", "O", "o"]],
1161 [0x01d3, 0x01dc, ["U", "u"]], [0x01de, 0x01e1, ["A", "a"]],
1162 [0x01e2, 0x01e3, ["AE", "ae"]],
1163 [0x01e4, 0x01ed, ["G", "g", "G", "g", "K", "k", "O", "o", "O", "o"]],
1164 [0x01f0, 0x01f5, ["j", "D", "G", "g"]],
1165 [0x01fa, 0x01fb, ["A", "a"]], [0x01fc, 0x01fd, ["AE", "ae"]],
1166 [0x01fe, 0x0217, ["O", "o", "A", "a", "A", "a", "E", "e", "E", "e",
1167 "I", "i", "I", "i", "O", "o", "O", "o", "R", "r", "R", "r", "U",
1169 [0x0253, 0x0257, ["b", "c", "d", "d"]],
1170 [0x0260, 0x0269, ["g", "h", "h", "i", "i"]],
1171 [0x026b, 0x0273, ["l", "l", "l", "l", "m", "n", "n"]],
1172 [0x027c, 0x028b, ["r", "r", "r", "r", "s", "t", "u", "u", "v"]],
1173 [0x0290, 0x0291, ["z"]], [0x029d, 0x02a0, ["j", "q"]],
1174 [0x1e00, 0x1e09, ["A", "a", "B", "b", "B", "b", "B", "b", "C", "c"]],
1175 [0x1e0a, 0x1e13, ["D", "d"]], [0x1e14, 0x1e1d, ["E", "e"]],
1176 [0x1e1e, 0x1e21, ["F", "f", "G", "g"]], [0x1e22, 0x1e2b, ["H", "h"]],
1177 [0x1e2c, 0x1e8f, ["I", "i", "I", "i", "K", "k", "K", "k", "K", "k",
1178 "L", "l", "L", "l", "L", "l", "L", "l", "M", "m", "M", "m", "M",
1179 "m", "N", "n", "N", "n", "N", "n", "N", "n", "O", "o", "O", "o",
1180 "O", "o", "O", "o", "P", "p", "P", "p", "R", "r", "R", "r", "R",
1181 "r", "R", "r", "S", "s", "S", "s", "S", "s", "S", "s", "S", "s",
1182 "T", "t", "T", "t", "T", "t", "T", "t", "U", "u", "U", "u", "U",
1183 "u", "U", "u", "U", "u", "V", "v", "V", "v", "W", "w", "W", "w",
1184 "W", "w", "W", "w", "W", "w", "X", "x", "X", "x", "Y", "y"]],
1185 [0x1e90, 0x1e9a, ["Z", "z", "Z", "z", "Z", "z", "h", "t", "w", "y", "a"]],
1186 [0x1ea0, 0x1eb7, ["A", "a"]], [0x1eb8, 0x1ec7, ["E", "e"]],
1187 [0x1ec8, 0x1ecb, ["I", "i"]], [0x1ecc, 0x1ee3, ["O", "o"]],
1188 [0x1ee4, 0x1ef1, ["U", "u"]], [0x1ef2, 0x1ef9, ["Y", "y"]],
1189 [0x2071, 0x2071, ["i"]], [0x207f, 0x207f, ["n"]],
1190 [0x249c, 0x24b5, "a"], [0x24b6, 0x24cf, "A"],
1191 [0x24d0, 0x24e9, "a"],
1192 [0xfb00, 0xfb06, ["ff", "fi", "fl", "ffi", "ffl", "st", "st"]],
1193 [0xff21, 0xff3a, "A"], [0xff41, 0xff5a, "a"]
1194 ].forEach(function ([start, stop, val]) {
1195 if (typeof val != "string")
1196 for (let i = start; i <= stop; i++)
1197 table[String.fromCharCode(i)] = val[(i - start) % val.length];
1199 let n = val.charCodeAt(0);
1200 for (let i = start; i <= stop; i++)
1201 table[String.fromCharCode(i)] = String.fromCharCode(n + i - start);
1206 indexOf: function indexOf(dest, src) {
1207 let table = this.translitTable;
1208 var end = dest.length - src.length;
1209 if (src.length == 0)
1212 for (var i = 0; i <= end; i++) {
1214 for (var k = 0; k < src.length;) {
1217 for (var l = 0; l < s.length; l++, k++) {
1220 if (k == src.length - 1)
1228 Mode: Struct("HintMode", "name", "prompt", "action", "filter")
1231 modes: function initModes() {
1232 initModes.require("commandline");
1233 modes.addMode("HINTS", {
1235 description: "Active when selecting elements with hints",
1236 bases: [modes.COMMAND_LINE],
1241 mappings: function () {
1242 let bind = function bind(names, description, action, params)
1243 mappings.add(config.browserModes, names, description,
1248 function () { hints.show("o"); });
1251 "Start Hints mode, but open link in a new tab",
1252 function () { hints.show(options.get("activate").has("links") ? "t" : "b"); });
1255 "Start an extended hints mode",
1256 function ({ count }) { hints.open(";", { count: count }); },
1260 "Start an extended hints mode and stay there until <Esc> is pressed",
1261 function ({ count }) { hints.open("g;", { continue: true, count: count }); },
1264 let bind = function bind(names, description, action, params)
1265 mappings.add([modes.HINTS], names, description,
1269 "Follow the selected hint",
1270 function ({ self }) { self.update(true); });
1273 "Focus the next matching hint",
1274 function ({ self }) { self.tab(false); });
1277 "Focus the previous matching hint",
1278 function ({ self }) { self.tab(true); });
1280 bind(["<BS>", "<C-h>"],
1281 "Delete the previous character",
1282 function ({ self }) self.backspace());
1285 "Toggle hint filtering",
1286 function ({ self }) { self.escapeNumbers = !self.escapeNumbers; });
1288 options: function () {
1289 options.add(["extendedhinttags", "eht"],
1290 "XPath or CSS selector strings of hintable elements for extended hint modes",
1292 // Make sure to update the docs when you change this.
1294 "[asOTvVWy]": [":-moz-any-link", "area[href]", "img[src]", "iframe[src]"],
1295 "[A]": ["[id]", "a[name]"],
1297 "[F]": ["body", "code", "div", "html", "p", "pre", "span"],
1298 "[S]": ["input:not([type=hidden])", "textarea", "button", "select"]
1302 getKey: function (val, default_)
1303 let (res = array.nth(this.value, re => let (match = re.exec(val)) match && match[0] == val, 0))
1304 res ? res.matcher : default_,
1305 parse: function parse(val) {
1306 let vals = parse.supercall(this, val);
1307 for (let value in values(vals))
1308 value.matcher = DOM.compileMatcher(Option.splitList(value.result));
1311 testValues: function testValues(vals, validator) vals.every(re => Option.splitList(re).every(validator)),
1312 validator: DOM.validateMatcher
1315 options.add(["hinttags", "ht"],
1316 "XPath or CSS selector strings of hintable elements for Hints mode",
1317 // Make sure to update the docs when you change this.
1318 "stringlist", ":-moz-any-link,area,button,iframe,input:not([type=hidden]),label[for],select,textarea," +
1319 "[onclick],[onmouseover],[onmousedown],[onmouseup],[oncommand]," +
1320 "[tabindex],[role=link],[role=button],[contenteditable=true]",
1322 setter: function (values) {
1323 this.matcher = DOM.compileMatcher(values);
1326 validator: DOM.validateMatcher
1329 options.add(["hintkeys", "hk"],
1330 "The keys used to label and select hints",
1331 "string", "0123456789",
1334 "0123456789": "Numbers",
1335 "asdfg;lkjh": "Home Row"
1337 validator: function (value) {
1338 let values = DOM.Event.parse(value).map(DOM.Event.closure.stringify);
1339 return Option.validIf(array.uniq(values).length === values.length && values.length > 1,
1340 _("option.hintkeys.duplicate"));
1344 options.add(["hinttimeout", "hto"],
1345 "Timeout before automatically following a non-unique numerical hint",
1347 { validator: function (value) value >= 0 });
1349 options.add(["followhints", "fh"],
1350 "Define the conditions under which selected hints are followed",
1354 "0": "Follow the first hint as soon as typed text uniquely identifies it. Follow the selected hint on <Return>.",
1355 "1": "Follow the selected hint on <Return>."
1359 options.add(["hintmatching", "hm"],
1360 "How hints are filtered",
1361 "stringlist", "contains",
1364 "contains": "The typed characters are split on whitespace. The resulting groups must all appear in the hint.",
1365 "custom": "Delegate to a custom function: dactyl.plugins.customHintMatcher(hintString)",
1366 "firstletters": "Behaves like wordstartswith, but all groups must match a sequence of words.",
1367 "wordstartswith": "The typed characters are split on whitespace. The resulting groups must all match the beginnings of words, in order.",
1368 "transliterated": UTF8("When true, special latin characters are translated to their ASCII equivalents (e.g., é ⇒ e)")
1370 validator: function (values) Option.validateCompleter.call(this, values) &&
1371 1 === values.reduce((acc, v) => acc + (["contains", "custom", "firstletters", "wordstartswith"].indexOf(v) >= 0),
1375 options.add(["wordseparators", "wsp"],
1376 "Regular expression defining which characters separate words when matching hints",
1377 "string", '[.,!?:;/"^$%&?()[\\]{}<>#*+|=~ _-]',
1378 { validator: function (value) RegExp(value) });
1380 options.add(["hintinputs", "hin"],
1381 "Which text is used to filter hints for input elements",
1382 "stringlist", "label,value",
1385 "value": "Match against the value of the input field",
1386 "label": "Match against the text of a label for the input field, if one can be found",
1387 "name": "Match against the name of the input field"
1393 // vim: set fdm=marker sw=4 sts=4 ts=8 et: