// Copyright (c) 2006-2008 by Martin Stubenschrott // Copyright (c) 2007-2011 by Doug Kearns // Copyright (c) 2008-2012 Kris Maglione // // This work is licensed for reuse under an MIT license. Details are // given in the LICENSE.txt file included with this file. "use strict"; /** @scope modules */ /** @instance hints */ var HintSession = Class("HintSession", CommandMode, { get extendedMode() modes.HINTS, init: function init(mode, opts) { init.supercall(this); opts = opts || {}; if (!opts.window) opts.window = modes.getStack(0).params.window; this.hintMode = hints.modes[mode]; dactyl.assert(this.hintMode); this.activeTimeout = null; // needed for hinttimeout > 0 this.continue = Boolean(opts.continue); this.docs = []; this.hintKeys = DOM.Event.parse(options["hintkeys"]).map(DOM.Event.closure.stringify); this.hintNumber = 0; this.hintString = opts.filter || ""; this.pageHints = []; this.prevInput = ""; this.usedTabKey = false; this.validHints = []; // store the indices of the "hints" array with valid elements mappings.pushCommand(); this.open(); this.top = opts.window || content; this.top.addEventListener("resize", this.closure._onResize, true); this.top.addEventListener("dactyl-commandupdate", this.closure._onResize, false, true); this.generate(); this.show(); this.magic = true; if (this.validHints.length == 0) { dactyl.beep(); modes.pop(); } else if (this.validHints.length == 1 && !this.continue) this.process(false); else this.checkUnique(); }, Hint: { get active() this._active, set active(val) { this._active = val; if (val) this.span.setAttribute("active", true); else this.span.removeAttribute("active"); hints.setClass(this.elem, this.valid ? val : null); if (this.imgSpan) hints.setClass(this.imgSpan, this.valid ? val : null); }, get ambiguous() this.span.hasAttribute("ambiguous"), set ambiguous(val) { let meth = val ? "setAttribute" : "removeAttribute"; this.elem[meth]("ambiguous", "true"); this.span[meth]("ambiguous", "true"); if (this.imgSpan) this.imgSpan[meth]("ambiguous", "true"); }, get valid() this._valid, set valid(val) { this._valid = val, this.span.style.display = (val ? "" : "none"); if (this.imgSpan) this.imgSpan.style.display = (val ? "" : "none"); this.active = this.active; } }, get mode() modes.HINTS, get prompt() ["Question", UTF8(this.hintMode.prompt) + ": "], leave: function leave(stack) { leave.superapply(this, arguments); if (!stack.push) { mappings.popCommand(); if (hints.hintSession == this) hints.hintSession = null; if (this.top) { this.top.removeEventListener("resize", this.closure._onResize, true); this.top.removeEventListener("dactyl-commandupdate", this.closure._onResize, true); } this.removeHints(0); } }, checkUnique: function _checkUnique() { if (this.hintNumber == 0) return; dactyl.assert(this.hintNumber <= this.validHints.length); // if we write a numeric part like 3, but we have 45 hints, only follow // the hint after a timeout, as the user might have wanted to follow link 34 if (this.hintNumber > 0 && this.hintNumber * this.hintKeys.length <= this.validHints.length) { let timeout = options["hinttimeout"]; if (timeout > 0) this.activeTimeout = this.timeout(function () { this.process(true); }, timeout); } else // we have a unique hint this.process(true); }, /** * Clear any timeout which might be active after pressing a number */ clearTimeout: function () { if (this.activeTimeout) this.activeTimeout.cancel(); this.activeTimeout = null; }, _escapeNumbers: false, get escapeNumbers() this._escapeNumbers, set escapeNumbers(val) { this.clearTimeout(); this._escapeNumbers = !!val; if (val && this.usedTabKey) this.hintNumber = 0; this.updateStatusline(); }, /** * Returns the hint string for a given number based on the values of * the 'hintkeys' option. * * @param {number} n The number to transform. * @returns {string} */ getHintString: function getHintString(n) { let res = [], len = this.hintKeys.length; do { res.push(this.hintKeys[n % len]); n = Math.floor(n / len); } while (n > 0); return res.reverse().join(""); }, /** * The reverse of {@link #getHintString}. Given a hint string, * returns its index. * * @param {string} str The hint's string. * @returns {number} The hint's index. */ getHintNumber: function getHintNumber(str) { let base = this.hintKeys.length; let res = 0; for (let char in values(str)) res = res * base + this.hintKeys.indexOf(char); return res; }, /** * Returns true if the given key string represents a * pseudo-hint-number. * * @param {string} key The key to test. * @returns {boolean} Whether the key represents a hint number. */ isHintKey: function isHintKey(key) this.hintKeys.indexOf(key) >= 0, /** * Gets the actual offset of an imagemap area. * * Only called by {@link #_generate}. * * @param {Object} elem The element. * @param {number} leftPos The left offset of the image. * @param {number} topPos The top offset of the image. * @returns [leftPos, topPos] The updated offsets. */ getAreaOffset: function _getAreaOffset(elem, leftPos, topPos) { try { // Need to add the offset to the area element. // Always try to find the top-left point, as per dactyl default. let shape = elem.getAttribute("shape").toLowerCase(); let coordStr = elem.getAttribute("coords"); // Technically it should be only commas, but hey coordStr = coordStr.replace(/\s+[;,]\s+/g, ",").replace(/\s+/g, ","); let coords = coordStr.split(",").map(Number); if ((shape == "rect" || shape == "rectangle") && coords.length == 4) { leftPos += coords[0]; topPos += coords[1]; } else if (shape == "circle" && coords.length == 3) { leftPos += coords[0] - coords[2] / Math.sqrt(2); topPos += coords[1] - coords[2] / Math.sqrt(2); } else if ((shape == "poly" || shape == "polygon") && coords.length % 2 == 0) { let leftBound = Infinity; let topBound = Infinity; // First find the top-left corner of the bounding rectangle (offset from image topleft can be noticeably suboptimal) for (let i = 0; i < coords.length; i += 2) { leftBound = Math.min(coords[i], leftBound); topBound = Math.min(coords[i + 1], topBound); } let curTop = null; let curLeft = null; let curDist = Infinity; // Then find the closest vertex. (we could generalize to nearest point on an edge, but I doubt there is a need) for (let i = 0; i < coords.length; i += 2) { let leftOffset = coords[i] - leftBound; let topOffset = coords[i + 1] - topBound; let dist = Math.sqrt(leftOffset * leftOffset + topOffset * topOffset); if (dist < curDist) { curDist = dist; curLeft = coords[i]; curTop = coords[i + 1]; } } // If we found a satisfactory offset, let's use it. if (curDist < Infinity) return [leftPos + curLeft, topPos + curTop]; } } catch (e) {} // badly formed document, or shape == "default" in which case we don't move the hint return [leftPos, topPos]; }, // the containing block offsets with respect to the viewport getContainerOffsets: function _getContainerOffsets(doc) { let body = doc.body || doc.documentElement; // TODO: getComputedStyle returns null for Facebook channel_iframe doc - probable Gecko bug. let style = DOM(body).style; if (style && /^(absolute|fixed|relative)$/.test(style.position)) { let rect = body.getClientRects()[0]; return [-rect.left, -rect.top]; } else return [doc.defaultView.scrollX, doc.defaultView.scrollY]; }, /** * Generate the hints in a window. * * Pushes the hints into the pageHints object, but does not display them. * * @param {Window} win The window for which to generate hints. * @default content */ generate: function _generate(win, offsets) { if (!win) win = this.top; let doc = win.document; memoize(doc, "dactylLabels", function () iter([l.getAttribute("for"), l] for (l in array.iterValues(doc.querySelectorAll("label[for]")))) .toObject()); let [offsetX, offsetY] = this.getContainerOffsets(doc); offsets = offsets || { left: 0, right: 0, top: 0, bottom: 0 }; offsets.right = win.innerWidth - offsets.right; offsets.bottom = win.innerHeight - offsets.bottom; function isVisible(elem) { let rect = elem.getBoundingClientRect(); if (!rect || rect.top > offsets.bottom || rect.bottom < offsets.top || rect.left > offsets.right || rect.right < offsets.left) return false; if (!rect.width || !rect.height) if (!Array.some(elem.childNodes, function (elem) elem instanceof Element && DOM(elem).style.float != "none" && isVisible(elem))) if (elem.textContent || !elem.name) return false; let computedStyle = doc.defaultView.getComputedStyle(elem, null); if (computedStyle.visibility != "visible" || computedStyle.display == "none") return false; return true; } let body = doc.body || doc.querySelector("body"); if (body) { let fragment = DOM(["div", { highlight: "hints" }], doc).appendTo(body); fragment.style.height; // Force application of binding. let container = doc.getAnonymousElementByAttribute(fragment[0], "anonid", "hints") || fragment[0]; let baseNode = DOM(["span", { highlight: "Hint", style: "display: none;" }], doc)[0]; let mode = this.hintMode; let res = mode.matcher(doc); let start = this.pageHints.length; let _hints = []; for (let elem in res) if (isVisible(elem) && (!mode.filter || mode.filter(elem))) _hints.push({ elem: elem, rect: elem.getClientRects()[0] || elem.getBoundingClientRect(), showText: false, __proto__: this.Hint }); for (let hint in values(_hints)) { let { elem, rect } = hint; if (elem.hasAttributeNS(NS, "hint")) [hint.text, hint.showText] = [elem.getAttributeNS(NS, "hint"), true]; else if (isinstance(elem, [HTMLInputElement, HTMLSelectElement, HTMLTextAreaElement])) [hint.text, hint.showText] = hints.getInputHint(elem, doc); else if (elem.firstElementChild instanceof HTMLImageElement && /^\s*$/.test(elem.textContent)) [hint.text, hint.showText] = [elem.firstElementChild.alt || elem.firstElementChild.title, true]; else hint.text = elem.textContent.toLowerCase(); hint.span = baseNode.cloneNode(false); let leftPos = Math.max((rect.left + offsetX), offsetX); let topPos = Math.max((rect.top + offsetY), offsetY); if (elem instanceof HTMLAreaElement) [leftPos, topPos] = this.getAreaOffset(elem, leftPos, topPos); hint.span.setAttribute("style", ["display: none; left:", leftPos, "px; top:", topPos, "px"].join("")); container.appendChild(hint.span); this.pageHints.push(hint); } this.docs.push({ doc: doc, start: start, end: this.pageHints.length - 1 }); } Array.forEach(win.frames, function (f) { if (isVisible(f.frameElement)) { let rect = f.frameElement.getBoundingClientRect(); this.generate(f, { left: Math.max(offsets.left - rect.left, 0), right: Math.max(rect.right - offsets.right, 0), top: Math.max(offsets.top - rect.top, 0), bottom: Math.max(rect.bottom - offsets.bottom, 0) }); } }, this); return true; }, /** * Handle user input. * * Will update the filter on displayed hints and follow the final hint if * necessary. * * @param {Event} event The keypress event. */ onChange: function onChange(event) { this.prevInput = "text"; this.clearTimeout(); this.hintNumber = 0; this.hintString = commandline.command; this.updateStatusline(); this.show(); if (this.validHints.length == 1) this.process(false); }, /** * Handle a hints mode event. * * @param {Event} event The event to handle. */ onKeyPress: function onKeyPress(eventList) { const KILL = false, PASS = true; let key = DOM.Event.stringify(eventList[0]); this.clearTimeout(); if (!this.escapeNumbers && this.isHintKey(key)) { this.prevInput = "number"; let oldHintNumber = this.hintNumber; if (this.usedTabKey) { this.hintNumber = 0; this.usedTabKey = false; } this.hintNumber = this.hintNumber * this.hintKeys.length + this.hintKeys.indexOf(key); this.updateStatusline(); if (this.docs.length) this.updateValidNumbers(); else { this.generate(); this.show(); } this.showActiveHint(this.hintNumber, oldHintNumber || 1); dactyl.assert(this.hintNumber != 0); this.checkUnique(); return KILL; } return PASS; }, onResize: function onResize() { this.removeHints(0); this.generate(this.top); this.show(); }, _onResize: function _onResize() { if (this.magic) hints.resizeTimer.tell(); }, /** * Finish hinting. * * Called when there are one or zero hints in order to possibly activate it * and, if activated, to clean up the rest of the hinting system. * * @param {boolean} followFirst Whether to force the following of the first * link (when 'followhints' is 1 or 2) * */ process: function _processHints(followFirst) { dactyl.assert(this.validHints.length > 0); // This "followhints" option is *too* confusing. For me, and // presumably for users, too. --Kris if (options["followhints"] > 0 && !followFirst) return; // no return hit; don't examine uniqueness if (!followFirst) { let firstHref = this.validHints[0].elem.getAttribute("href") || null; if (firstHref) { if (this.validHints.some(function (h) h.elem.getAttribute("href") != firstHref)) return; } else if (this.validHints.length > 1) return; } let timeout = followFirst || events.feedingKeys ? 0 : 500; let activeIndex = (this.hintNumber ? this.hintNumber - 1 : 0); let elem = this.validHints[activeIndex].elem; let top = this.top; if (this.continue) this._reset(); else this.removeHints(timeout); let n = 5; (function next() { let hinted = n || this.validHints.some(function (h) h.elem === elem); if (!hinted) hints.setClass(elem, null); else if (n) hints.setClass(elem, n % 2); else hints.setClass(elem, this.validHints[Math.max(0, this.hintNumber - 1)].elem === elem); if (n--) this.timeout(next, 50); }).call(this); mappings.pushCommand(); if (!this.continue) { modes.pop(); if (timeout) modes.push(modes.IGNORE, modes.HINTS); } dactyl.trapErrors("action", this.hintMode, elem, elem.href || elem.src || "", this.extendedhintCount, top); mappings.popCommand(); this.timeout(function () { if (modes.main == modes.IGNORE && !this.continue) modes.pop(); commandline.lastEcho = null; // Hack. if (this.continue && this.top) this.show(); }, timeout); }, /** * Remove all hints from the document, and reset the completions. * * Lingers on the active hint briefly to confirm the selection to the user. * * @param {number} timeout The number of milliseconds before the active * hint disappears. */ removeHints: function _removeHints(timeout) { for (let { doc, start, end } in values(this.docs)) { DOM(doc.documentElement).highlight.remove("Hinting"); // Goddamn stupid fucking Gecko 1.x security manager bullshit. try { delete doc.dactylLabels; } catch (e) { doc.dactylLabels = undefined; } for (let elem in DOM.XPath("//*[@dactyl:highlight='hints']", doc)) elem.parentNode.removeChild(elem); for (let i in util.range(start, end + 1)) { this.pageHints[i].ambiguous = false; this.pageHints[i].valid = false; } } styles.system.remove("hint-positions"); this.reset(); }, reset: function reset() { this.pageHints = []; this.validHints = []; this.docs = []; this.clearTimeout(); }, _reset: function _reset() { if (!this.usedTabKey) this.hintNumber = 0; if (this.continue && this.validHints.length <= 1) { this.hintString = ""; commandline.widgets.command = this.hintString; this.show(); } this.updateStatusline(); }, /** * Display the hints in pageHints that are still valid. */ showCount: 0, show: function _show() { let count = ++this.showCount; let hintnum = 1; let validHint = hints.hintMatcher(this.hintString.toLowerCase()); let activeHint = this.hintNumber || 1; this.validHints = []; for (let { doc, start, end } in values(this.docs)) { DOM(doc.documentElement).highlight.add("Hinting"); let [offsetX, offsetY] = this.getContainerOffsets(doc); inner: for (let i in (util.interruptibleRange(start, end + 1, 500))) { if (this.showCount != count) return; let hint = this.pageHints[i]; hint.valid = validHint(hint.text); if (!hint.valid) continue inner; if (hint.text == "" && hint.elem.firstChild && hint.elem.firstChild instanceof HTMLImageElement) { if (!hint.imgSpan) { let rect = hint.elem.firstChild.getBoundingClientRect(); if (!rect) continue; hint.imgSpan = DOM(["span", { highlight: "Hint", "dactyl:hl": "HintImage" }], doc).css({ display: "none", left: (rect.left + offsetX) + "px", top: (rect.top + offsetY) + "px", width: (rect.right - rect.left) + "px", height: (rect.bottom - rect.top) + "px" }).appendTo(hint.span.parentNode)[0]; } } let str = this.getHintString(hintnum); let text = []; if (hint.elem instanceof HTMLInputElement) if (hint.elem.type === "radio") text.push(UTF8(hint.elem.checked ? "⊙" : "○")); else if (hint.elem.type === "checkbox") text.push(UTF8(hint.elem.checked ? "☑" : "☐")); if (hint.showText && !/^\s*$/.test(hint.text)) text.push(hint.text.substr(0, 50)); hint.span.setAttribute("text", str + (text.length ? ": " + text.join(" ") : "")); hint.span.setAttribute("number", str); if (hint.imgSpan) hint.imgSpan.setAttribute("number", str); hint.active = activeHint == hintnum; this.validHints.push(hint); hintnum++; } } let base = this.hintKeys.length; for (let [i, hint] in Iterator(this.validHints)) hint.ambiguous = (i + 1) * base <= this.validHints.length; if (options["usermode"]) { let css = []; for (let hint in values(this.pageHints)) { let selector = highlight.selector("Hint") + "[number=" + hint.span.getAttribute("number").quote() + "]"; let imgSpan = "[dactyl|hl=HintImage]"; css.push(selector + ":not(" + imgSpan + ") { " + hint.span.style.cssText + " }"); if (hint.imgSpan) css.push(selector + imgSpan + " { " + hint.span.style.cssText + " }"); } styles.system.add("hint-positions", "*", css.join("\n")); } return true; }, /** * Update the activeHint. * * By default highlights it green instead of yellow. * * @param {number} newId The hint to make active. * @param {number} oldId The currently active hint. */ showActiveHint: function _showActiveHint(newId, oldId) { let oldHint = this.validHints[oldId - 1]; if (oldHint) oldHint.active = false; let newHint = this.validHints[newId - 1]; if (newHint) newHint.active = true; }, backspace: function () { this.clearTimeout(); if (this.prevInput !== "number") return Events.PASS; if (this.hintNumber > 0 && !this.usedTabKey) { this.hintNumber = Math.floor(this.hintNumber / this.hintKeys.length); if (this.hintNumber == 0) this.prevInput = "text"; this.update(false); } else { this.usedTabKey = false; this.hintNumber = 0; dactyl.beep(); } return Events.KILL; }, updateValidNumbers: function updateValidNumbers(always) { let string = this.getHintString(this.hintNumber); for (let hint in values(this.validHints)) hint.valid = always || hint.span.getAttribute("number").indexOf(string) == 0; }, tab: function tab(previous) { this.clearTimeout(); this.usedTabKey = true; if (this.hintNumber == 0) this.hintNumber = 1; let oldId = this.hintNumber; if (!previous) { if (++this.hintNumber > this.validHints.length) this.hintNumber = 1; } else { if (--this.hintNumber < 1) this.hintNumber = this.validHints.length; } this.updateValidNumbers(true); this.showActiveHint(this.hintNumber, oldId); this.updateStatusline(); }, update: function update(followFirst) { this.clearTimeout(); this.updateStatusline(); if (this.docs.length == 0 && this.hintString.length > 0) this.generate(); this.show(); this.process(followFirst); }, /** * Display the current status to the user. */ updateStatusline: function _updateStatusline() { statusline.inputBuffer = (this.escapeNumbers ? "\\" : "") + (this.hintNumber ? this.getHintString(this.hintNumber) : ""); }, }); var Hints = Module("hints", { init: function init() { this.resizeTimer = Timer(100, 500, function () { if (isinstance(modes.main, modes.HINTS)) modes.getStack(0).params.onResize(); }); let appContent = document.getElementById("appcontent"); if (appContent) events.listen(appContent, "scroll", this.resizeTimer.closure.tell, false); const Mode = Hints.Mode; Mode.prototype.__defineGetter__("matcher", function () options.get("extendedhinttags").getKey(this.name, options.get("hinttags").matcher)); this.modes = {}; this.addMode(";", "Focus hint", buffer.closure.focusElement); this.addMode("?", "Show information for hint", function (elem) buffer.showElementInfo(elem)); // TODO: allow for ! override to overwrite existing paths -- where? --djk this.addMode("s", "Save hint", function (elem) buffer.saveLink(elem, false)); this.addMode("f", "Focus frame", function (elem) dactyl.focus(elem.ownerDocument.defaultView)); this.addMode("F", "Focus frame or pseudo-frame", buffer.closure.focusElement, isScrollable); this.addMode("o", "Follow hint", function (elem) buffer.followLink(elem, dactyl.CURRENT_TAB)); this.addMode("t", "Follow hint in a new tab", function (elem) buffer.followLink(elem, dactyl.NEW_TAB)); this.addMode("b", "Follow hint in a background tab", function (elem) buffer.followLink(elem, dactyl.NEW_BACKGROUND_TAB)); this.addMode("w", "Follow hint in a new window", function (elem) buffer.followLink(elem, dactyl.NEW_WINDOW)); this.addMode("O", "Generate an ‘:open URL’ prompt", function (elem, loc) CommandExMode().open("open " + loc)); this.addMode("T", "Generate a ‘:tabopen URL’ prompt", function (elem, loc) CommandExMode().open("tabopen " + loc)); this.addMode("W", "Generate a ‘:winopen URL’ prompt", function (elem, loc) CommandExMode().open("winopen " + loc)); this.addMode("a", "Add a bookmark", function (elem) bookmarks.addSearchKeyword(elem)); this.addMode("S", "Add a search keyword", function (elem) bookmarks.addSearchKeyword(elem)); this.addMode("v", "View hint source", function (elem, loc) buffer.viewSource(loc, false)); this.addMode("V", "View hint source in external editor", function (elem, loc) buffer.viewSource(loc, true)); this.addMode("y", "Yank hint location", function (elem, loc) editor.setRegister(null, loc, true)); this.addMode("Y", "Yank hint description", function (elem) editor.setRegister(null, elem.textContent || "", true)); this.addMode("A", "Yank hint anchor url", function (elem) { let uri = elem.ownerDocument.documentURIObject.clone(); uri.ref = elem.id || elem.name; dactyl.clipboardWrite(uri.spec, true); }); this.addMode("c", "Open context menu", function (elem) DOM(elem).contextmenu()); this.addMode("i", "Show image", function (elem) dactyl.open(elem.src)); this.addMode("I", "Show image in a new tab", function (elem) dactyl.open(elem.src, dactyl.NEW_TAB)); function isScrollable(elem) isinstance(elem, [HTMLFrameElement, HTMLIFrameElement]) || Buffer.isScrollable(elem, 0, true) || Buffer.isScrollable(elem, 0, false); }, hintSession: Modes.boundProperty(), /** * Creates a new hints mode. * * @param {string} mode The letter that identifies this mode. * @param {string} prompt The description to display to the user * about this mode. * @param {function(Node)} action The function to be called with the * element that matches. * @param {function(Node):boolean} filter A function used to filter * the returned node set. * @param {[string]} tags A value to add to the default * 'extendedhinttags' value for this mode. * @optional */ addMode: function (mode, prompt, action, filter, tags) { function toString(regexp) RegExp.prototype.toString.call(regexp); if (tags != null) { let eht = options.get("extendedhinttags"); let update = eht.isDefault; let value = eht.parse(Option.quote(util.regexp.escape(mode)) + ":" + tags.map(Option.quote))[0]; eht.defaultValue = eht.defaultValue.filter(function (re) toString(re) != toString(value)) .concat(value); if (update) eht.reset(); } this.modes[mode] = Hints.Mode(mode, UTF8(prompt), action, filter); }, /** * Get a hint for "input", "textarea" and "select". * * Tries to use