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-2014 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.bound.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.bound._onResize, true);
40 this.top.addEventListener("dactyl-commandupdate", this.bound._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.bound._onResize, true);
105 this.top.removeEventListener("dactyl-commandupdate", this.bound._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);
310 if (computedStyle.visibility != "visible" || computedStyle.display == "none")
315 let body = doc.body || doc.querySelector("body");
317 let fragment = DOM(["div", { highlight: "hints" }], doc).appendTo(body);
318 fragment.style.height; // Force application of binding.
319 let container = doc.getAnonymousElementByAttribute(fragment[0], "anonid", "hints") || fragment[0];
321 let baseNode = DOM(["span", { highlight: "Hint", style: "display: none;" }], doc)[0];
323 let mode = this.hintMode;
324 let res = mode.matcher(doc);
326 let start = this.pageHints.length;
328 for (let elem in res)
329 if (isVisible(elem) && (!mode.filter || mode.filter(elem)))
332 rect: elem.getClientRects()[0] || elem.getBoundingClientRect(),
337 for (let hint of _hints) {
338 let { elem, rect } = hint;
340 if (elem.hasAttributeNS(NS, "hint"))
341 [hint.text, hint.showText] = [elem.getAttributeNS(NS, "hint"), true];
342 else if (isinstance(elem, [Ci.nsIDOMHTMLInputElement,
343 Ci.nsIDOMHTMLSelectElement,
344 Ci.nsIDOMHTMLTextAreaElement]))
345 [hint.text, hint.showText] = hints.getInputHint(elem, doc);
346 else if (elem.firstElementChild instanceof Ci.nsIDOMHTMLImageElement && /^\s*$/.test(elem.textContent))
347 [hint.text, hint.showText] = [elem.firstElementChild.alt || elem.firstElementChild.title, true];
349 hint.text = elem.textContent.toLowerCase();
351 hint.span = baseNode.cloneNode(false);
353 let leftPos = Math.max((rect.left + offsetX), offsetX);
354 let topPos = Math.max((rect.top + offsetY), offsetY);
356 if (elem instanceof Ci.nsIDOMHTMLAreaElement)
357 [leftPos, topPos] = this.getAreaOffset(elem, leftPos, topPos);
359 hint.span.setAttribute("style", ["display: none; left:", leftPos, "px; top:", topPos, "px"].join(""));
360 container.appendChild(hint.span);
362 this.pageHints.push(hint);
365 this.docs.push({ doc: doc, start: start, end: this.pageHints.length - 1 });
368 Array.forEach(win.frames, function (f) {
369 if (isVisible(f.frameElement)) {
370 let rect = f.frameElement.getBoundingClientRect();
372 left: Math.max(offsets.left - rect.left, 0),
373 right: Math.max(rect.right - offsets.right, 0),
374 top: Math.max(offsets.top - rect.top, 0),
375 bottom: Math.max(rect.bottom - offsets.bottom, 0)
386 * Will update the filter on displayed hints and follow the final hint if
389 * @param {Event} event The keypress event.
391 onChange: function onChange(event) {
392 this.prevInput = "text";
397 this.hintString = commandline.command;
398 this.updateStatusline();
400 if (this.validHints.length == 1)
405 * Handle a hints mode event.
407 * @param {Event} event The event to handle.
409 onKeyPress: function onKeyPress(eventList) {
410 const KILL = false, PASS = true;
411 let key = DOM.Event.stringify(eventList[0]);
415 if (!this.escapeNumbers && this.isHintKey(key)) {
416 this.prevInput = "number";
418 let oldHintNumber = this.hintNumber;
419 if (this.usedTabKey) {
421 this.usedTabKey = false;
423 this.hintNumber = this.hintNumber * this.hintKeys.length +
424 this.hintKeys.indexOf(key);
426 this.updateStatusline();
428 if (this.docs.length)
429 this.updateValidNumbers();
435 this.showActiveHint(this.hintNumber, oldHintNumber || 1);
437 dactyl.assert(this.hintNumber != 0);
446 onResize: function onResize() {
448 this.generate(this.top);
452 _onResize: function _onResize() {
454 hints.resizeTimer.tell();
460 * Called when there are one or zero hints in order to possibly activate it
461 * and, if activated, to clean up the rest of the hinting system.
463 * @param {boolean} followFirst Whether to force the following of the first
464 * link (when 'followhints' is 1 or 2)
467 process: function _processHints(followFirst) {
468 dactyl.assert(this.validHints.length > 0);
470 // This "followhints" option is *too* confusing. For me, and
471 // presumably for users, too. --Kris
472 if (options["followhints"] > 0 && !followFirst)
473 return; // no return hit; don't examine uniqueness
476 let firstHref = this.validHints[0].elem.getAttribute("href") || null;
478 if (this.validHints.some(h => h.elem.getAttribute("href") != firstHref))
481 else if (this.validHints.length > 1)
485 let timeout = followFirst || events.feedingKeys ? 0 : 500;
486 let activeIndex = (this.hintNumber ? this.hintNumber - 1 : 0);
487 let elem = this.validHints[activeIndex].elem;
493 this.removeHints(timeout);
497 if (Cu.isDeadWrapper && Cu.isDeadWrapper(elem))
498 // Hint document has been unloaded.
501 let hinted = n || this.validHints.some(h => h.elem === elem);
503 hints.setClass(elem, null);
505 hints.setClass(elem, n % 2);
507 hints.setClass(elem, this.validHints[Math.max(0, this.hintNumber - 1)].elem === elem);
510 this.timeout(next, 50);
513 mappings.pushCommand();
514 if (!this.continue) {
517 modes.push(modes.IGNORE, modes.HINTS);
520 dactyl.trapErrors("action", this.hintMode,
521 elem, elem.href || elem.src || "",
522 this.extendedhintCount, top);
523 mappings.popCommand();
525 this.timeout(function () {
526 if (modes.main == modes.IGNORE && !this.continue)
528 commandline.lastEcho = null; // Hack.
529 if (this.continue && this.top)
535 * Remove all hints from the document, and reset the completions.
537 * Lingers on the active hint briefly to confirm the selection to the user.
539 * @param {number} timeout The number of milliseconds before the active
542 removeHints: function _removeHints(timeout) {
543 for (let { doc, start, end } in values(this.docs)) {
544 DOM(doc.documentElement).highlight.remove("Hinting");
545 // Goddamn stupid fucking Gecko 1.x security manager bullshit.
546 try { delete doc.dactylLabels; } catch (e) { doc.dactylLabels = undefined; }
548 for (let elem in DOM.XPath("//*[@dactyl:highlight='hints']", doc))
549 elem.parentNode.removeChild(elem);
550 for (let i in util.range(start, end + 1)) {
551 this.pageHints[i].ambiguous = false;
552 this.pageHints[i].valid = false;
555 styles.system.remove("hint-positions");
560 reset: function reset() {
562 this.validHints = [];
566 _reset: function _reset() {
567 if (!this.usedTabKey)
569 if (this.continue && this.validHints.length <= 1) {
570 this.hintString = "";
571 commandline.widgets.command = this.hintString;
574 this.updateStatusline();
578 * Display the hints in pageHints that are still valid.
581 show: function _show() {
582 let count = ++this.showCount;
584 let validHint = hints.hintMatcher(this.hintString.toLowerCase());
585 let activeHint = this.hintNumber || 1;
586 this.validHints = [];
588 for (let { doc, start, end } in values(this.docs)) {
589 DOM(doc.documentElement).highlight.add("Hinting");
590 let [offsetX, offsetY] = this.getContainerOffsets(doc);
593 for (let i in (util.interruptibleRange(start, end + 1, 500))) {
594 if (this.showCount != count)
597 let hint = this.pageHints[i];
599 hint.valid = validHint(hint.text);
603 if (hint.text == "" && hint.elem.firstChild && hint.elem.firstChild instanceof Ci.nsIDOMHTMLImageElement) {
605 let rect = hint.elem.firstChild.getBoundingClientRect();
609 hint.imgSpan = DOM(["span", { highlight: "Hint", "dactyl:hl": "HintImage" }], doc).css({
611 left: (rect.left + offsetX) + "px",
612 top: (rect.top + offsetY) + "px",
613 width: (rect.right - rect.left) + "px",
614 height: (rect.bottom - rect.top) + "px"
615 }).appendTo(hint.span.parentNode)[0];
619 let str = this.getHintString(hintnum);
621 if (hint.elem instanceof Ci.nsIDOMHTMLInputElement)
622 if (hint.elem.type === "radio")
623 text.push(UTF8(hint.elem.checked ? "⊙" : "○"));
624 else if (hint.elem.type === "checkbox")
625 text.push(UTF8(hint.elem.checked ? "☑" : "☐"));
626 if (hint.showText && !/^\s*$/.test(hint.text))
627 text.push(hint.text.substr(0, 50));
629 hint.span.setAttribute("text", str + (text.length ? ": " + text.join(" ") : ""));
630 hint.span.setAttribute("number", str);
632 hint.imgSpan.setAttribute("number", str);
634 hint.active = activeHint == hintnum;
636 this.validHints.push(hint);
641 let base = this.hintKeys.length;
642 for (let [i, hint] in Iterator(this.validHints))
643 hint.ambiguous = (i + 1) * base <= this.validHints.length;
645 if (options["usermode"]) {
647 for (let hint in values(this.pageHints)) {
648 let selector = highlight.selector("Hint") + "[number=" + hint.span.getAttribute("number").quote() + "]";
649 let imgSpan = "[dactyl|hl=HintImage]";
650 css.push(selector + ":not(" + imgSpan + ") { " + hint.span.style.cssText + " }");
652 css.push(selector + imgSpan + " { " + hint.span.style.cssText + " }");
654 styles.system.add("hint-positions", "*", css.join("\n"));
661 * Update the activeHint.
663 * By default highlights it green instead of yellow.
665 * @param {number} newId The hint to make active.
666 * @param {number} oldId The currently active hint.
668 showActiveHint: function _showActiveHint(newId, oldId) {
669 let oldHint = this.validHints[oldId - 1];
671 oldHint.active = false;
673 let newHint = this.validHints[newId - 1];
675 newHint.active = true;
678 backspace: function () {
680 if (this.prevInput !== "number")
683 if (this.hintNumber > 0 && !this.usedTabKey) {
684 this.hintNumber = Math.floor(this.hintNumber / this.hintKeys.length);
685 if (this.hintNumber == 0)
686 this.prevInput = "text";
690 this.usedTabKey = false;
697 updateValidNumbers: function updateValidNumbers(always) {
698 let string = this.getHintString(this.hintNumber);
699 for (let hint in values(this.validHints))
700 hint.valid = always || hint.span.getAttribute("number").startsWith(string);
703 tab: function tab(previous) {
705 this.usedTabKey = true;
706 if (this.hintNumber == 0)
709 let oldId = this.hintNumber;
711 if (++this.hintNumber > this.validHints.length)
715 if (--this.hintNumber < 1)
716 this.hintNumber = this.validHints.length;
719 this.updateValidNumbers(true);
720 this.showActiveHint(this.hintNumber, oldId);
721 this.updateStatusline();
724 update: function update(followFirst) {
726 this.updateStatusline();
728 if (this.docs.length == 0 && this.hintString.length > 0)
732 this.process(followFirst);
736 * Display the current status to the user.
738 updateStatusline: function _updateStatusline() {
739 statusline.inputBuffer = (this.escapeNumbers ? "\\" : "") +
740 (this.hintNumber ? this.getHintString(this.hintNumber) : "");
744 var Hints = Module("hints", {
745 init: function init() {
746 this.resizeTimer = Timer(100, 500, function () {
747 if (isinstance(modes.main, modes.HINTS))
748 modes.getStack(0).params.onResize();
751 let appContent = document.getElementById("appcontent");
753 events.listen(appContent, "scroll", this.resizeTimer.bound.tell, false);
755 const Mode = Hints.Mode;
756 Mode.prototype.__defineGetter__("matcher", function ()
757 options.get("extendedhinttags").getKey(this.name, options.get("hinttags").matcher));
759 function cleanLoc(loc) {
761 let uri = util.newURI(loc);
762 if (uri.scheme == "mailto" && !~uri.path.indexOf("?"))
770 this.addMode(";", "Focus hint", buffer.bound.focusElement);
771 this.addMode("?", "Show information for hint", elem => buffer.showElementInfo(elem));
772 // TODO: allow for ! override to overwrite existing paths -- where? --djk
773 this.addMode("s", "Save hint", elem => buffer.saveLink(elem, false));
774 this.addMode("f", "Focus frame", elem => dactyl.focus(elem.ownerDocument.defaultView));
775 this.addMode("F", "Focus frame or pseudo-frame", buffer.bound.focusElement, isScrollable);
776 this.addMode("o", "Follow hint", elem => buffer.followLink(elem, dactyl.CURRENT_TAB));
777 this.addMode("t", "Follow hint in a new tab", elem => buffer.followLink(elem, dactyl.NEW_TAB));
778 this.addMode("b", "Follow hint in a background tab", elem => buffer.followLink(elem, dactyl.NEW_BACKGROUND_TAB));
779 this.addMode("w", "Follow hint in a new window", elem => buffer.followLink(elem, dactyl.NEW_WINDOW));
780 this.addMode("O", "Generate an ‘:open URL’ prompt", (elem, loc) => CommandExMode().open("open " + loc));
781 this.addMode("T", "Generate a ‘:tabopen URL’ prompt", (elem, loc) => CommandExMode().open("tabopen " + loc));
782 this.addMode("W", "Generate a ‘:winopen URL’ prompt", (elem, loc) => CommandExMode().open("winopen " + loc));
783 this.addMode("a", "Add a bookmark", elem => bookmarks.addSearchKeyword(elem));
784 this.addMode("S", "Add a search keyword", elem => bookmarks.addSearchKeyword(elem));
785 this.addMode("v", "View hint source", (elem, loc) => buffer.viewSource(loc, false));
786 this.addMode("V", "View hint source in external editor", (elem, loc) => buffer.viewSource(loc, true));
787 this.addMode("y", "Yank hint location", (elem, loc) => editor.setRegister(null, cleanLoc(loc), true));
788 this.addMode("Y", "Yank hint description", elem => editor.setRegister(null, elem.textContent || "", true));
789 this.addMode("A", "Yank hint anchor url", function (elem) {
790 let uri = elem.ownerDocument.documentURIObject.clone();
791 uri.ref = elem.id || elem.name;
792 dactyl.clipboardWrite(uri.spec, true);
794 this.addMode("c", "Open context menu", elem => DOM(elem).contextmenu());
795 this.addMode("i", "Show image", elem => dactyl.open(elem.src));
796 this.addMode("I", "Show image in a new tab", elem => dactyl.open(elem.src, dactyl.NEW_TAB));
798 function isScrollable(elem) isinstance(elem, [Ci.nsIDOMHTMLFrameElement,
799 Ci.nsIDOMHTMLIFrameElement]) ||
800 Buffer.isScrollable(elem, 0, true) || Buffer.isScrollable(elem, 0, false);
803 hintSession: Modes.boundProperty(),
806 * Creates a new hints mode.
808 * @param {string} mode The letter that identifies this mode.
809 * @param {string} prompt The description to display to the user
811 * @param {function(Node)} action The function to be called with the
812 * element that matches.
813 * @param {function(Node):boolean} filter A function used to filter
814 * the returned node set.
815 * @param {[string]} tags A value to add to the default
816 * 'extendedhinttags' value for this mode.
819 addMode: function (mode, prompt, action, filter, tags) {
820 function toString(regexp) RegExp.prototype.toString.call(regexp);
823 let eht = options.get("extendedhinttags");
824 let update = eht.isDefault;
826 let value = eht.parse(Option.quote(util.regexp.escape(mode)) + ":" + tags.map(Option.quote))[0];
827 eht.defaultValue = eht.defaultValue.filter(re => toString(re) != toString(value))
834 this.modes[mode] = Hints.Mode(mode, UTF8(prompt), action, filter);
838 * Get a hint for "input", "textarea" and "select".
840 * Tries to use <label>s if possible but does not try to guess that a
841 * neighboring element might look like a label. Only called by
842 * {@link #_generate}.
844 * If it finds a hint it returns it, if the hint is not the caption of the
845 * element it will return showText=true.
847 * @param {Object} elem The element used to generate hint text.
848 * @param {Document} doc The containing document.
850 * @returns [text, showText]
852 getInputHint: function _getInputHint(elem, doc) {
853 // <input type="submit|button|reset"/> Always use the value
854 // <input type="radio|checkbox"/> Use the value if it is not numeric or label or name
855 // <input type="password"/> Never use the value, use label or name
856 // <input type="text|file"/> <textarea/> Use value if set or label or name
857 // <input type="image"/> Use the alt text if present (showText) or label or name
858 // <input type="hidden"/> Never gets here
859 // <select/> Use the text of the selected item or label or name
861 let type = elem.type;
863 if (DOM(elem).isInput)
864 return [elem.value, false];
866 for (let [, option] in Iterator(options["hintinputs"])) {
867 if (option == "value") {
868 if (elem instanceof Ci.nsIDOMHTMLSelectElement) {
869 if (elem.selectedIndex >= 0)
870 return [elem.item(elem.selectedIndex).text.toLowerCase(), false];
872 else if (type == "image") {
874 return [elem.alt.toLowerCase(), true];
876 else if (elem.value && type != "password") {
877 // radios and checkboxes often use internal ids as values - maybe make this an option too...
878 if (! ((type == "radio" || type == "checkbox") && !isNaN(elem.value)))
879 return [elem.value.toLowerCase(), (type == "radio" || type == "checkbox")];
882 else if (option == "label") {
884 let label = (elem.ownerDocument.dactylLabels || {})[elem.id];
887 return [label.textContent.toLowerCase(), true];
890 else if (option == "name")
891 return [elem.name.toLowerCase(), true];
899 * Get the hintMatcher according to user preference.
901 * @param {string} hintString The currently typed hint.
902 * @returns {hintMatcher}
904 hintMatcher: function _hintMatcher(hintString) { //{{{
906 * Divide a string by a regular expression.
908 * @param {RegExp|string} pat The pattern to split on.
909 * @param {string} str The string to split.
910 * @returns {Array(string)} The lowercased splits of the splitting.
912 function tokenize(pat, str) str.split(pat).map(String.toLowerCase);
915 * Get a hint matcher for hintmatching=contains
917 * The hintMatcher expects the user input to be space delimited and it
918 * returns true if each set of characters typed can be found, in any
919 * order, in the link.
921 * @param {string} hintString The string typed by the user.
922 * @returns {function(String):boolean} A function that takes the text
923 * of a hint and returns true if all the (space-delimited) sets of
924 * characters typed by the user can be found in it.
926 function containsMatcher(hintString) { //{{{
927 let tokens = tokenize(/\s+/, hintString);
928 return function (linkText) {
929 linkText = linkText.toLowerCase();
930 return tokens.every(token => indexOf(linkText, token) >= 0);
935 * Get a hintMatcher for hintmatching=firstletters|wordstartswith
937 * The hintMatcher will look for any division of the user input that
938 * would match the first letters of words. It will always only match
941 * @param {string} hintString The string typed by the user.
942 * @param {boolean} allowWordOverleaping Whether to allow non-contiguous
944 * @returns {function(String):boolean} A function that will filter only
945 * hints that match as above.
947 function wordStartsWithMatcher(hintString, allowWordOverleaping) { //{{{
948 let hintStrings = tokenize(/\s+/, hintString);
949 let wordSplitRegexp = util.regexp(options["wordseparators"]);
952 * Match a set of characters to the start of words.
954 * What the **** does this do? --Kris
955 * This function matches hintStrings like 'hekho' to links
956 * like 'Hey Kris, how are you?' -> [HE]y [K]ris [HO]w are you
959 * @param {string} chars The characters to match.
960 * @param {Array(string)} words The words to match them against.
961 * @param {boolean} allowWordOverleaping Whether words may be
962 * skipped during matching.
963 * @returns {boolean} Whether a match can be found.
965 function charsAtBeginningOfWords(chars, words, allowWordOverleaping) {
966 function charMatches(charIdx, chars, wordIdx, words, inWordIdx, allowWordOverleaping) {
967 let matches = (chars[charIdx] == words[wordIdx][inWordIdx]);
968 if ((matches == false && allowWordOverleaping) || words[wordIdx].length == 0) {
969 let nextWordIdx = wordIdx + 1;
970 if (nextWordIdx == words.length)
973 return charMatches(charIdx, chars, nextWordIdx, words, 0, allowWordOverleaping);
977 let nextCharIdx = charIdx + 1;
978 if (nextCharIdx == chars.length)
981 let nextWordIdx = wordIdx + 1;
982 let beyondLastWord = (nextWordIdx == words.length);
983 let charMatched = false;
984 if (beyondLastWord == false)
985 charMatched = charMatches(nextCharIdx, chars, nextWordIdx, words, 0, allowWordOverleaping);
990 if (charMatched == false || beyondLastWord == true) {
991 let nextInWordIdx = inWordIdx + 1;
992 if (nextInWordIdx == words[wordIdx].length)
995 return charMatches(nextCharIdx, chars, wordIdx, words, nextInWordIdx, allowWordOverleaping);
1002 return charMatches(0, chars, 0, words, 0, allowWordOverleaping);
1006 * Check whether the array of strings all exist at the start of the
1009 * i.e. ['ro', 'e'] would match ['rollover', 'effect']
1011 * The matches must be in order, and, if allowWordOverleaping is
1012 * false, contiguous.
1014 * @param {Array(string)} strings The strings to search for.
1015 * @param {Array(string)} words The words to search in.
1016 * @param {boolean} allowWordOverleaping Whether matches may be
1018 * @returns {boolean} Whether all the strings matched.
1020 function stringsAtBeginningOfWords(strings, words, allowWordOverleaping) {
1022 for (let [, word] in Iterator(words)) {
1023 if (word.length == 0)
1026 let str = strings[strIdx];
1027 if (str.length == 0 || indexOf(word, str) == 0)
1029 else if (!allowWordOverleaping)
1032 if (strIdx == strings.length)
1036 for (; strIdx < strings.length; strIdx++) {
1037 if (strings[strIdx].length != 0)
1043 return function (linkText) {
1044 if (hintStrings.length == 1 && hintStrings[0].length == 0)
1047 let words = tokenize(wordSplitRegexp, linkText);
1048 if (hintStrings.length == 1)
1049 return charsAtBeginningOfWords(hintStrings[0], words, allowWordOverleaping);
1051 return stringsAtBeginningOfWords(hintStrings, words, allowWordOverleaping);
1055 let indexOf = String.indexOf;
1056 if (options.get("hintmatching").has("transliterated"))
1057 indexOf = Hints.bound.indexOf;
1059 switch (options["hintmatching"][0]) {
1060 case "contains" : return containsMatcher(hintString);
1061 case "wordstartswith": return wordStartsWithMatcher(hintString, true);
1062 case "firstletters" : return wordStartsWithMatcher(hintString, false);
1063 case "custom" : return dactyl.plugins.customHintMatcher(hintString);
1064 default : dactyl.echoerr(_("hints.noMatcher", hintMatching));
1069 open: function open(mode, opts={}) {
1070 this._extendedhintCount = opts.count;
1072 mappings.pushCommand();
1073 commandline.input(["Normal", mode], {
1074 autocomplete: false,
1075 completer: function (context) {
1076 context.compare = () => 0;
1077 context.completions = [[k, v.prompt] for ([k, v] in Iterator(hints.modes))];
1079 onCancel: mappings.bound.popCommand,
1080 onSubmit: function (arg) {
1082 hints.show(arg, opts);
1083 mappings.popCommand();
1085 onChange: function (arg) {
1086 if (Object.keys(hints.modes).some(m => m != arg && m.startsWith(arg)))
1089 this.accepted = true;
1096 * Toggle the highlight of a hint.
1098 * @param {Object} elem The element to toggle.
1099 * @param {boolean} active Whether it is the currently active hint or not.
1101 setClass: function _setClass(elem, active) {
1102 if (elem.dactylHighlight == null)
1103 elem.dactylHighlight = elem.getAttributeNS(NS, "highlight") || "";
1105 let prefix = (elem.getAttributeNS(NS, "hl") || "") + " " + elem.dactylHighlight + " ";
1107 highlight.highlightNode(elem, prefix + "HintActive");
1108 else if (active != null)
1109 highlight.highlightNode(elem, prefix + "HintElem");
1111 highlight.highlightNode(elem, elem.dactylHighlight);
1112 // delete elem.dactylHighlight fails on Gecko 1.9. Issue #197
1113 elem.dactylHighlight = null;
1117 show: function show(mode, opts) {
1118 this.hintSession = HintSession(mode, opts);
1121 isVisible: function isVisible(elem, offScreen) {
1122 let rect = elem.getBoundingClientRect();
1123 if (!rect.width || !rect.height)
1124 if (!Array.some(elem.childNodes, elem => elem instanceof Element
1125 && DOM(elem).style.float != "none"
1126 && isVisible(elem)))
1129 let win = elem.ownerDocument.defaultView;
1130 if (offScreen && (rect.top + win.scrollY < 0 || rect.left + win.scrollX < 0 ||
1131 rect.bottom + win.scrollY > win.scrolMaxY + win.innerHeight ||
1132 rect.right + win.scrollX > win.scrolMaxX + win.innerWidth))
1135 if (!DOM(elem).isVisible)
1140 translitTable: Class.Memoize(function () {
1143 [0x00c0, 0x00c6, ["A"]], [0x00c7, 0x00c7, ["C"]],
1144 [0x00c8, 0x00cb, ["E"]], [0x00cc, 0x00cf, ["I"]],
1145 [0x00d1, 0x00d1, ["N"]], [0x00d2, 0x00d6, ["O"]],
1146 [0x00d8, 0x00d8, ["O"]], [0x00d9, 0x00dc, ["U"]],
1147 [0x00dd, 0x00dd, ["Y"]], [0x00e0, 0x00e6, ["a"]],
1148 [0x00e7, 0x00e7, ["c"]], [0x00e8, 0x00eb, ["e"]],
1149 [0x00ec, 0x00ef, ["i"]], [0x00f1, 0x00f1, ["n"]],
1150 [0x00f2, 0x00f6, ["o"]], [0x00f8, 0x00f8, ["o"]],
1151 [0x00f9, 0x00fc, ["u"]], [0x00fd, 0x00fd, ["y"]],
1152 [0x00ff, 0x00ff, ["y"]], [0x0100, 0x0105, ["A", "a"]],
1153 [0x0106, 0x010d, ["C", "c"]], [0x010e, 0x0111, ["D", "d"]],
1154 [0x0112, 0x011b, ["E", "e"]], [0x011c, 0x0123, ["G", "g"]],
1155 [0x0124, 0x0127, ["H", "h"]], [0x0128, 0x0130, ["I", "i"]],
1156 [0x0132, 0x0133, ["IJ", "ij"]], [0x0134, 0x0135, ["J", "j"]],
1157 [0x0136, 0x0136, ["K", "k"]], [0x0139, 0x0142, ["L", "l"]],
1158 [0x0143, 0x0148, ["N", "n"]], [0x0149, 0x0149, ["n"]],
1159 [0x014c, 0x0151, ["O", "o"]], [0x0152, 0x0153, ["OE", "oe"]],
1160 [0x0154, 0x0159, ["R", "r"]], [0x015a, 0x0161, ["S", "s"]],
1161 [0x0162, 0x0167, ["T", "t"]], [0x0168, 0x0173, ["U", "u"]],
1162 [0x0174, 0x0175, ["W", "w"]], [0x0176, 0x0178, ["Y", "y", "Y"]],
1163 [0x0179, 0x017e, ["Z", "z"]], [0x0180, 0x0183, ["b", "B", "B", "b"]],
1164 [0x0187, 0x0188, ["C", "c"]], [0x0189, 0x0189, ["D"]],
1165 [0x018a, 0x0192, ["D", "D", "d", "F", "f"]],
1166 [0x0193, 0x0194, ["G"]],
1167 [0x0197, 0x019b, ["I", "K", "k", "l", "l"]],
1168 [0x019d, 0x01a1, ["N", "n", "O", "O", "o"]],
1169 [0x01a4, 0x01a5, ["P", "p"]], [0x01ab, 0x01ab, ["t"]],
1170 [0x01ac, 0x01b0, ["T", "t", "T", "U", "u"]],
1171 [0x01b2, 0x01d2, ["V", "Y", "y", "Z", "z", "D", "L", "N", "A", "a",
1172 "I", "i", "O", "o"]],
1173 [0x01d3, 0x01dc, ["U", "u"]], [0x01de, 0x01e1, ["A", "a"]],
1174 [0x01e2, 0x01e3, ["AE", "ae"]],
1175 [0x01e4, 0x01ed, ["G", "g", "G", "g", "K", "k", "O", "o", "O", "o"]],
1176 [0x01f0, 0x01f5, ["j", "D", "G", "g"]],
1177 [0x01fa, 0x01fb, ["A", "a"]], [0x01fc, 0x01fd, ["AE", "ae"]],
1178 [0x01fe, 0x0217, ["O", "o", "A", "a", "A", "a", "E", "e", "E", "e",
1179 "I", "i", "I", "i", "O", "o", "O", "o", "R", "r", "R", "r", "U",
1181 [0x0253, 0x0257, ["b", "c", "d", "d"]],
1182 [0x0260, 0x0269, ["g", "h", "h", "i", "i"]],
1183 [0x026b, 0x0273, ["l", "l", "l", "l", "m", "n", "n"]],
1184 [0x027c, 0x028b, ["r", "r", "r", "r", "s", "t", "u", "u", "v"]],
1185 [0x0290, 0x0291, ["z"]], [0x029d, 0x02a0, ["j", "q"]],
1186 [0x1e00, 0x1e09, ["A", "a", "B", "b", "B", "b", "B", "b", "C", "c"]],
1187 [0x1e0a, 0x1e13, ["D", "d"]], [0x1e14, 0x1e1d, ["E", "e"]],
1188 [0x1e1e, 0x1e21, ["F", "f", "G", "g"]], [0x1e22, 0x1e2b, ["H", "h"]],
1189 [0x1e2c, 0x1e8f, ["I", "i", "I", "i", "K", "k", "K", "k", "K", "k",
1190 "L", "l", "L", "l", "L", "l", "L", "l", "M", "m", "M", "m", "M",
1191 "m", "N", "n", "N", "n", "N", "n", "N", "n", "O", "o", "O", "o",
1192 "O", "o", "O", "o", "P", "p", "P", "p", "R", "r", "R", "r", "R",
1193 "r", "R", "r", "S", "s", "S", "s", "S", "s", "S", "s", "S", "s",
1194 "T", "t", "T", "t", "T", "t", "T", "t", "U", "u", "U", "u", "U",
1195 "u", "U", "u", "U", "u", "V", "v", "V", "v", "W", "w", "W", "w",
1196 "W", "w", "W", "w", "W", "w", "X", "x", "X", "x", "Y", "y"]],
1197 [0x1e90, 0x1e9a, ["Z", "z", "Z", "z", "Z", "z", "h", "t", "w", "y", "a"]],
1198 [0x1ea0, 0x1eb7, ["A", "a"]], [0x1eb8, 0x1ec7, ["E", "e"]],
1199 [0x1ec8, 0x1ecb, ["I", "i"]], [0x1ecc, 0x1ee3, ["O", "o"]],
1200 [0x1ee4, 0x1ef1, ["U", "u"]], [0x1ef2, 0x1ef9, ["Y", "y"]],
1201 [0x2071, 0x2071, ["i"]], [0x207f, 0x207f, ["n"]],
1202 [0x249c, 0x24b5, "a"], [0x24b6, 0x24cf, "A"],
1203 [0x24d0, 0x24e9, "a"],
1204 [0xfb00, 0xfb06, ["ff", "fi", "fl", "ffi", "ffl", "st", "st"]],
1205 [0xff21, 0xff3a, "A"], [0xff41, 0xff5a, "a"]
1206 ].forEach(function ([start, stop, val]) {
1207 if (typeof val != "string")
1208 for (let i = start; i <= stop; i++)
1209 table[String.fromCharCode(i)] = val[(i - start) % val.length];
1211 let n = val.charCodeAt(0);
1212 for (let i = start; i <= stop; i++)
1213 table[String.fromCharCode(i)] = String.fromCharCode(n + i - start);
1218 indexOf: function indexOf(dest, src) {
1219 let table = this.translitTable;
1220 var end = dest.length - src.length;
1221 if (src.length == 0)
1224 for (var i = 0; i <= end; i++) {
1226 for (var k = 0; k < src.length;) {
1229 for (var l = 0; l < s.length; l++, k++) {
1232 if (k == src.length - 1)
1240 Mode: Struct("HintMode", "name", "prompt", "action", "filter")
1243 modes: function initModes() {
1244 initModes.require("commandline");
1245 modes.addMode("HINTS", {
1247 description: "Active when selecting elements with hints",
1248 bases: [modes.COMMAND_LINE],
1253 mappings: function () {
1254 let bind = function bind(names, description, action, params)
1255 mappings.add(config.browserModes, names, description,
1260 function () { hints.show("o"); });
1263 "Start Hints mode, but open link in a new tab",
1264 function () { hints.show(options.get("activate").has("links") ? "t" : "b"); });
1267 "Start an extended hints mode",
1268 function ({ count }) { hints.open(";", { count: count }); },
1272 "Start an extended hints mode and stay there until <Esc> is pressed",
1273 function ({ count }) { hints.open("g;", { continue: true, count: count }); },
1276 let bind = function bind(names, description, action, params)
1277 mappings.add([modes.HINTS], names, description,
1281 "Follow the selected hint",
1282 function ({ self }) { self.update(true); });
1285 "Focus the next matching hint",
1286 function ({ self }) { self.tab(false); });
1289 "Focus the previous matching hint",
1290 function ({ self }) { self.tab(true); });
1292 bind(["<BS>", "<C-h>"],
1293 "Delete the previous character",
1294 function ({ self }) self.backspace());
1297 "Toggle hint filtering",
1298 function ({ self }) { self.escapeNumbers = !self.escapeNumbers; });
1300 options: function () {
1301 options.add(["extendedhinttags", "eht"],
1302 "XPath or CSS selector strings of hintable elements for extended hint modes",
1304 // Make sure to update the docs when you change this.
1306 "[asOTvVWy]": [":-moz-any-link", "area[href]", "img[src]", "iframe[src]"],
1307 "[A]": ["[id]", "a[name]"],
1309 "[F]": ["body", "code", "div", "html", "p", "pre", "span"],
1310 "[S]": ["input:not([type=hidden])", "textarea", "button", "select"]
1315 getKey: function (val, default_)
1316 let (res = this.value.find(re => let (match = re.exec(val)) match && match[0] == val))
1320 parse: function parse(val) {
1321 let vals = parse.supercall(this, val);
1322 for (let value in values(vals))
1323 value.matcher = DOM.compileMatcher(Option.splitList(value.result));
1327 testValues: function testValues(vals, validator) vals.every(re => Option.splitList(re).every(validator)),
1329 validator: DOM.validateMatcher
1332 options.add(["hinttags", "ht"],
1333 "XPath or CSS selector strings of hintable elements for Hints mode",
1334 // Make sure to update the docs when you change this.
1335 "stringlist", ":-moz-any-link,area,button,iframe,input:not([type=hidden]):not([disabled])," +
1336 "label[for],select,textarea," +
1337 "[onclick],[onmouseover],[onmousedown],[onmouseup],[oncommand]," +
1338 "[tabindex],[role=link],[role=button],[contenteditable=true]",
1340 setter: function (values) {
1341 this.matcher = DOM.compileMatcher(values);
1344 validator: DOM.validateMatcher
1347 options.add(["hintkeys", "hk"],
1348 "The keys used to label and select hints",
1349 "string", "0123456789",
1352 "0123456789": "Numbers",
1353 "asdfg;lkjh": "Home Row"
1355 validator: function (value) {
1356 let values = DOM.Event.parse(value).map(DOM.Event.bound.stringify);
1357 return Option.validIf(array.uniq(values).length === values.length && values.length > 1,
1358 _("option.hintkeys.duplicate"));
1362 options.add(["hinttimeout", "hto"],
1363 "Timeout before automatically following a non-unique numerical hint",
1365 { validator: function (value) value >= 0 });
1367 options.add(["followhints", "fh"],
1368 "Define the conditions under which selected hints are followed",
1372 "0": "Follow the first hint as soon as typed text uniquely identifies it. Follow the selected hint on <Return>.",
1373 "1": "Follow the selected hint on <Return>."
1377 options.add(["hintmatching", "hm"],
1378 "How hints are filtered",
1379 "stringlist", "contains",
1382 "contains": "The typed characters are split on whitespace. The resulting groups must all appear in the hint.",
1383 "custom": "Delegate to a custom function: dactyl.plugins.customHintMatcher(hintString)",
1384 "firstletters": "Behaves like wordstartswith, but all groups must match a sequence of words.",
1385 "wordstartswith": "The typed characters are split on whitespace. The resulting groups must all match the beginnings of words, in order.",
1386 "transliterated": UTF8("When true, special latin characters are translated to their ASCII equivalents (e.g., é ⇒ e)")
1388 validator: function (values) Option.validateCompleter.call(this, values) &&
1389 1 === values.reduce((acc, v) => acc + (["contains", "custom", "firstletters", "wordstartswith"].indexOf(v) >= 0),
1393 options.add(["wordseparators", "wsp"],
1394 "Regular expression defining which characters separate words when matching hints",
1395 "string", '[.,!?:;/"^$%&?()[\\]{}<>#*+|=~ _-]',
1396 { validator: function (value) RegExp(value) });
1398 options.add(["hintinputs", "hin"],
1399 "Which text is used to filter hints for input elements",
1400 "stringlist", "label,value",
1403 "value": "Match against the value of the input field",
1404 "label": "Match against the text of a label for the input field, if one can be found",
1405 "name": "Match against the name of the input field"
1411 // vim: set fdm=marker sw=4 sts=4 ts=8 et: