]> git.donarmstrong.com Git - dactyl.git/blob - common/content/buffer.js
af099e747e8d58bde657158747088141187b5654
[dactyl.git] / common / content / buffer.js
1 // Copyright (c) 2006-2008 by Martin Stubenschrott <stubenschrott@vimperator.org>
2 // Copyright (c) 2007-2011 by Doug Kearns <dougkearns@gmail.com>
3 // Copyright (c) 2008-2011 by Kris Maglione <maglione.k at Gmail>
4 //
5 // This work is licensed for reuse under an MIT license. Details are
6 // given in the LICENSE.txt file included with this file.
7 "use strict";
8
9 /** @scope modules */
10
11 /**
12  * A class to manage the primary web content buffer. The name comes
13  * from Vim's term, 'buffer', which signifies instances of open
14  * files.
15  * @instance buffer
16  */
17 var Buffer = Module("buffer", {
18     init: function init() {
19         this.evaluateXPath = util.evaluateXPath;
20         this.pageInfo = {};
21
22         this.addPageInfoSection("f", "Feeds", function (verbose) {
23             const feedTypes = {
24                 "application/rss+xml": "RSS",
25                 "application/atom+xml": "Atom",
26                 "text/xml": "XML",
27                 "application/xml": "XML",
28                 "application/rdf+xml": "XML"
29             };
30
31             function isValidFeed(data, principal, isFeed) {
32                 if (!data || !principal)
33                     return false;
34
35                 if (!isFeed) {
36                     var type = data.type && data.type.toLowerCase();
37                     type = type.replace(/^\s+|\s*(?:;.*)?$/g, "");
38
39                     isFeed = ["application/rss+xml", "application/atom+xml"].indexOf(type) >= 0 ||
40                              // really slimy: general XML types with magic letters in the title
41                              type in feedTypes && /\brss\b/i.test(data.title);
42                 }
43
44                 if (isFeed) {
45                     try {
46                         window.urlSecurityCheck(data.href, principal,
47                                 Ci.nsIScriptSecurityManager.DISALLOW_INHERIT_PRINCIPAL);
48                     }
49                     catch (e) {
50                         isFeed = false;
51                     }
52                 }
53
54                 if (type)
55                     data.type = type;
56
57                 return isFeed;
58             }
59
60             let nFeed = 0;
61             for (let [i, win] in Iterator(buffer.allFrames())) {
62                 let doc = win.document;
63
64                 for (let link in util.evaluateXPath(["link[@href and (@rel='feed' or (@rel='alternate' and @type))]"], doc)) {
65                     let rel = link.rel.toLowerCase();
66                     let feed = { title: link.title, href: link.href, type: link.type || "" };
67                     if (isValidFeed(feed, doc.nodePrincipal, rel == "feed")) {
68                         nFeed++;
69                         let type = feedTypes[feed.type] || "RSS";
70                         if (verbose)
71                             yield [feed.title, template.highlightURL(feed.href, true) + <span class="extra-info">&#xa0;({type})</span>];
72                     }
73                 }
74
75             }
76
77             if (!verbose && nFeed)
78                 yield nFeed + " feed" + (nFeed > 1 ? "s" : "");
79         });
80
81         this.addPageInfoSection("g", "General Info", function (verbose) {
82             let doc = buffer.focusedFrame.document;
83
84             // get file size
85             const ACCESS_READ = Ci.nsICache.ACCESS_READ;
86             let cacheKey = doc.documentURI;
87
88             for (let proto in array.iterValues(["HTTP", "FTP"])) {
89                 try {
90                     var cacheEntryDescriptor = services.cache.createSession(proto, 0, true)
91                                                        .openCacheEntry(cacheKey, ACCESS_READ, false);
92                     break;
93                 }
94                 catch (e) {}
95             }
96
97             let pageSize = []; // [0] bytes; [1] kbytes
98             if (cacheEntryDescriptor) {
99                 pageSize[0] = util.formatBytes(cacheEntryDescriptor.dataSize, 0, false);
100                 pageSize[1] = util.formatBytes(cacheEntryDescriptor.dataSize, 2, true);
101                 if (pageSize[1] == pageSize[0])
102                     pageSize.length = 1; // don't output "xx Bytes" twice
103             }
104
105             let lastModVerbose = new Date(doc.lastModified).toLocaleString();
106             let lastMod = new Date(doc.lastModified).toLocaleFormat("%x %X");
107
108             if (lastModVerbose == "Invalid Date" || new Date(doc.lastModified).getFullYear() == 1970)
109                 lastModVerbose = lastMod = null;
110
111             if (!verbose) {
112                 if (pageSize[0])
113                     yield (pageSize[1] || pageSize[0]) + " bytes";
114                 yield lastMod;
115                 return;
116             }
117
118             yield ["Title", doc.title];
119             yield ["URL", template.highlightURL(doc.location.href, true)];
120
121             let ref = "referrer" in doc && doc.referrer;
122             if (ref)
123                 yield ["Referrer", template.highlightURL(ref, true)];
124
125             if (pageSize[0])
126                 yield ["File Size", pageSize[1] ? pageSize[1] + " (" + pageSize[0] + ")"
127                                                 : pageSize[0]];
128
129             yield ["Mime-Type", doc.contentType];
130             yield ["Encoding", doc.characterSet];
131             yield ["Compatibility", doc.compatMode == "BackCompat" ? "Quirks Mode" : "Full/Almost Standards Mode"];
132             if (lastModVerbose)
133                 yield ["Last Modified", lastModVerbose];
134         });
135
136         this.addPageInfoSection("m", "Meta Tags", function (verbose) {
137             // get meta tag data, sort and put into pageMeta[]
138             let metaNodes = buffer.focusedFrame.document.getElementsByTagName("meta");
139
140             return Array.map(metaNodes, function (node) [(node.name || node.httpEquiv), template.highlightURL(node.content)])
141                         .sort(function (a, b) util.compareIgnoreCase(a[0], b[0]));
142         });
143
144         dactyl.commands["buffer.viewSource"] = function (event) {
145             let elem = event.originalTarget;
146             buffer.viewSource([elem.getAttribute("href"), Number(elem.getAttribute("line"))]);
147         };
148     },
149
150     // called when the active document is scrolled
151     _updateBufferPosition: function _updateBufferPosition() {
152         statusline.updateBufferPosition();
153         commandline.clear(true);
154     },
155
156     /**
157      * @property {Array} The alternative style sheets for the current
158      *     buffer. Only returns style sheets for the 'screen' media type.
159      */
160     get alternateStyleSheets() {
161         let stylesheets = window.getAllStyleSheets(this.focusedFrame);
162
163         return stylesheets.filter(
164             function (stylesheet) /^(screen|all|)$/i.test(stylesheet.media.mediaText) && !/^\s*$/.test(stylesheet.title)
165         );
166     },
167
168     climbUrlPath: function climbUrlPath(count) {
169         let url = buffer.documentURI.clone();
170         dactyl.assert(url instanceof Ci.nsIURL);
171
172         while (count-- && url.path != "/")
173             url.path = url.path.replace(/[^\/]+\/*$/, "");
174
175         dactyl.assert(!url.equals(buffer.documentURI));
176         dactyl.open(url.spec);
177     },
178
179     incrementURL: function incrementURL(count) {
180         let matches = buffer.uri.spec.match(/(.*?)(\d+)(\D*)$/);
181         dactyl.assert(matches);
182         let oldNum = matches[2];
183
184         // disallow negative numbers as trailing numbers are often proceeded by hyphens
185         let newNum = String(Math.max(parseInt(oldNum, 10) + count, 0));
186         if (/^0/.test(oldNum))
187             while (newNum.length < oldNum.length)
188                 newNum = "0" + newNum;
189
190         matches[2] = newNum;
191         dactyl.open(matches.slice(1).join(""));
192     },
193
194     /**
195      * @property {Object} A map of page info sections to their
196      *     content generating functions.
197      */
198     pageInfo: null,
199
200     /**
201      * @property {number} True when the buffer is fully loaded.
202      */
203     get loaded() Math.min.apply(null,
204         this.allFrames()
205             .map(function (frame) ["loading", "interactive", "complete"]
206                                       .indexOf(frame.document.readyState))),
207
208     /**
209      * @property {Object} The local state store for the currently selected
210      *     tab.
211      */
212     get localStore() {
213         if (!content.document.dactylStore)
214             content.document.dactylStore = {};
215         return content.document.dactylStore;
216     },
217
218     /**
219      * @property {Node} The last focused input field in the buffer. Used
220      *     by the "gi" key binding.
221      */
222     get lastInputField() {
223         let field = this.localStore.lastInputField && this.localStore.lastInputField.get();
224         let doc = field && field.ownerDocument;
225         let win = doc && doc.defaultView;
226         return win && doc === win.document ? field : null;
227     },
228     set lastInputField(value) { this.localStore.lastInputField = value && Cu.getWeakReference(value); },
229
230     /**
231      * @property {nsIURI} The current top-level document.
232      */
233     get doc() window.content.document,
234
235     /**
236      * @property {nsIURI} The current top-level document's URI.
237      */
238     get uri() util.newURI(content.location.href),
239
240     /**
241      * @property {nsIURI} The current top-level document's URI, sans any
242      *     fragment identifier.
243      */
244     get documentURI() let (doc = content.document) doc.documentURIObject || util.newURI(doc.documentURI),
245
246     /**
247      * @property {string} The current top-level document's URL.
248      */
249     get URL() update(new String(content.location.href), util.newURI(content.location.href)),
250
251     /**
252      * @property {number} The buffer's height in pixels.
253      */
254     get pageHeight() content.innerHeight,
255
256     /**
257      * @property {number} The current browser's zoom level, as a
258      *     percentage with 100 as 'normal'.
259      */
260     get zoomLevel() config.browser.markupDocumentViewer[this.fullZoom ? "fullZoom" : "textZoom"] * 100,
261     set zoomLevel(value) { this.setZoom(value, this.fullZoom); },
262
263     /**
264      * @property {boolean} Whether the current browser is using full
265      *     zoom, as opposed to text zoom.
266      */
267     get fullZoom() ZoomManager.useFullZoom,
268     set fullZoom(value) { this.setZoom(this.zoomLevel, value); },
269
270     /**
271      * @property {string} The current document's title.
272      */
273     get title() content.document.title,
274
275     /**
276      * @property {number} The buffer's horizontal scroll percentile.
277      */
278     get scrollXPercent() {
279         let elem = this.findScrollable(0, true);
280         if (elem.scrollWidth - elem.clientWidth === 0)
281             return 0;
282         return elem.scrollLeft * 100 / (elem.scrollWidth - elem.clientWidth);
283     },
284
285     /**
286      * @property {number} The buffer's vertical scroll percentile.
287      */
288     get scrollYPercent() {
289         let elem = this.findScrollable(0, false);
290         if (elem.scrollHeight - elem.clientHeight === 0)
291             return 0;
292         return elem.scrollTop * 100 / (elem.scrollHeight - elem.clientHeight);
293     },
294
295     /**
296      * Adds a new section to the page information output.
297      *
298      * @param {string} option The section's value in 'pageinfo'.
299      * @param {string} title The heading for this section's
300      *     output.
301      * @param {function} func The function to generate this
302      *     section's output.
303      */
304     addPageInfoSection: function addPageInfoSection(option, title, func) {
305         this.pageInfo[option] = Buffer.PageInfo(option, title, func);
306     },
307
308     /**
309      * Returns a list of all frames in the given window or current buffer.
310      */
311     allFrames: function allFrames(win, focusedFirst) {
312         let frames = [];
313         (function rec(frame) {
314             if (frame.document.body instanceof HTMLBodyElement)
315                 frames.push(frame);
316             Array.forEach(frame.frames, rec);
317         })(win || content);
318         if (focusedFirst)
319             return frames.filter(function (f) f === buffer.focusedFrame).concat(
320                     frames.filter(function (f) f !== buffer.focusedFrame));
321         return frames;
322     },
323
324     /**
325      * @property {Window} Returns the currently focused frame.
326      */
327     get focusedFrame() {
328         let frame = this.localStore.focusedFrame;
329         return frame && frame.get() || content;
330     },
331     set focusedFrame(frame) {
332         this.localStore.focusedFrame = Cu.getWeakReference(frame);
333     },
334
335     /**
336      * Returns the currently selected word. If the selection is
337      * null, it tries to guess the word that the caret is
338      * positioned in.
339      *
340      * @returns {string}
341      */
342     get currentWord() Buffer.currentWord(this.focusedFrame),
343     getCurrentWord: deprecated("buffer.currentWord", function getCurrentWord() this.currentWord),
344
345     /**
346      * Returns true if a scripts are allowed to focus the given input
347      * element or input elements in the given window.
348      *
349      * @param {Node|Window}
350      * @returns {boolean}
351      */
352     focusAllowed: function focusAllowed(elem) {
353         if (elem instanceof Window && !Editor.getEditor(elem))
354             return true;
355         let doc = elem.ownerDocument || elem.document || elem;
356         return !options["strictfocus"] || doc.dactylFocusAllowed;
357     },
358
359     /**
360      * Focuses the given element. In contrast to a simple
361      * elem.focus() call, this function works for iframes and
362      * image maps.
363      *
364      * @param {Node} elem The element to focus.
365      */
366     focusElement: function focusElement(elem) {
367         let win = elem.ownerDocument && elem.ownerDocument.defaultView || elem;
368         win.document.dactylFocusAllowed = true;
369
370         if (isinstance(elem, [HTMLFrameElement, HTMLIFrameElement]))
371             elem = elem.contentWindow;
372         if (elem.document)
373             elem.document.dactylFocusAllowed = true;
374
375         if (elem instanceof HTMLInputElement && elem.type == "file") {
376             Buffer.openUploadPrompt(elem);
377             this.lastInputField = elem;
378         }
379         else {
380             if (isinstance(elem, [HTMLInputElement, XULTextBoxElement]))
381                 var flags = services.focus.FLAG_BYMOUSE;
382             else
383                 flags = services.focus.FLAG_SHOWRING;
384             dactyl.focus(elem, flags);
385
386             if (elem instanceof Window) {
387                 let sel = elem.getSelection();
388                 if (sel && !sel.rangeCount)
389                     sel.addRange(RangeFind.endpoint(
390                         RangeFind.nodeRange(elem.document.body || elem.document.documentElement),
391                         true));
392             }
393             else {
394                 let range = RangeFind.nodeRange(elem);
395                 let sel = (elem.ownerDocument || elem).defaultView.getSelection();
396                 if (!sel.rangeCount || !RangeFind.intersects(range, sel.getRangeAt(0))) {
397                     range.collapse(true);
398                     sel.removeAllRanges();
399                     sel.addRange(range);
400                 }
401             }
402
403             // for imagemap
404             if (elem instanceof HTMLAreaElement) {
405                 try {
406                     let [x, y] = elem.getAttribute("coords").split(",").map(parseFloat);
407
408                     events.dispatch(elem, events.create(elem.ownerDocument, "mouseover", { screenX: x, screenY: y }));
409                 }
410                 catch (e) {}
411             }
412         }
413     },
414
415     /**
416      * Find the counth last link on a page matching one of the given
417      * regular expressions, or with a @rel or @rev attribute matching
418      * the given relation. Each frame is searched beginning with the
419      * last link and progressing to the first, once checking for
420      * matching @rel or @rev properties, and then once for each given
421      * regular expression. The first match is returned. All frames of
422      * the page are searched, beginning with the currently focused.
423      *
424      * If follow is true, the link is followed.
425      *
426      * @param {string} rel The relationship to look for.
427      * @param {[RegExp]} regexps The regular expressions to search for.
428      * @param {number} count The nth matching link to follow.
429      * @param {bool} follow Whether to follow the matching link.
430      * @param {string} path The CSS to use for the search. @optional
431      */
432     followDocumentRelationship: deprecated("buffer.findLink",
433         function followDocumentRelationship(rel) {
434             this.findLink(rel, options[rel + "pattern"], 0, true);
435         }),
436     findLink: function findLink(rel, regexps, count, follow, path) {
437         let selector = path || options.get("hinttags").stringDefaultValue;
438
439         function followFrame(frame) {
440             function iter(elems) {
441                 for (let i = 0; i < elems.length; i++)
442                     if (elems[i].rel.toLowerCase() === rel || elems[i].rev.toLowerCase() === rel)
443                         yield elems[i];
444             }
445
446             let elems = frame.document.getElementsByTagName("link");
447             for (let elem in iter(elems))
448                 yield elem;
449
450             elems = frame.document.getElementsByTagName("a");
451             for (let elem in iter(elems))
452                 yield elem;
453
454             let res = frame.document.querySelectorAll(selector);
455             for (let regexp in values(regexps)) {
456                 for (let i in util.range(res.length, 0, -1)) {
457                     let elem = res[i];
458                     if (regexp.test(elem.textContent) === regexp.result || regexp.test(elem.title) === regexp.result ||
459                             Array.some(elem.childNodes, function (child) regexp.test(child.alt) === regexp.result))
460                         yield elem;
461                 }
462             }
463         }
464
465         for (let frame in values(this.allFrames(null, true)))
466             for (let elem in followFrame(frame))
467                 if (count-- === 0) {
468                     if (follow)
469                         this.followLink(elem, dactyl.CURRENT_TAB);
470                     return elem;
471                 }
472
473         if (follow)
474             dactyl.beep();
475     },
476
477     /**
478      * Fakes a click on a link.
479      *
480      * @param {Node} elem The element to click.
481      * @param {number} where Where to open the link. See
482      *     {@link dactyl.open}.
483      */
484     followLink: function followLink(elem, where) {
485         let doc = elem.ownerDocument;
486         let view = doc.defaultView;
487         let { left: offsetX, top: offsetY } = elem.getBoundingClientRect();
488
489         if (isinstance(elem, [HTMLFrameElement, HTMLIFrameElement]))
490             return this.focusElement(elem);
491         if (isinstance(elem, HTMLLinkElement))
492             return dactyl.open(elem.href, where);
493
494         if (elem instanceof HTMLAreaElement) { // for imagemap
495             let coords = elem.getAttribute("coords").split(",");
496             offsetX = Number(coords[0]) + 1;
497             offsetY = Number(coords[1]) + 1;
498         }
499         else if (elem instanceof HTMLInputElement && elem.type == "file") {
500             Buffer.openUploadPrompt(elem);
501             return;
502         }
503
504         let ctrlKey = false, shiftKey = false;
505         switch (where) {
506         case dactyl.NEW_TAB:
507         case dactyl.NEW_BACKGROUND_TAB:
508             ctrlKey = true;
509             shiftKey = (where != dactyl.NEW_BACKGROUND_TAB);
510             break;
511         case dactyl.NEW_WINDOW:
512             shiftKey = true;
513             break;
514         case dactyl.CURRENT_TAB:
515             break;
516         }
517
518         this.focusElement(elem);
519
520         prefs.withContext(function () {
521             prefs.set("browser.tabs.loadInBackground", true);
522             ["mousedown", "mouseup", "click"].slice(0, util.haveGecko("2b") ? 2 : 3)
523                 .forEach(function (event) {
524                 events.dispatch(elem, events.create(doc, event, {
525                     screenX: offsetX, screenY: offsetY,
526                     ctrlKey: ctrlKey, shiftKey: shiftKey, metaKey: ctrlKey
527                 }));
528             });
529         });
530     },
531
532     /**
533      * @property {nsISelectionController} The current document's selection
534      *     controller.
535      */
536     get selectionController() config.browser.docShell
537             .QueryInterface(Ci.nsIInterfaceRequestor).getInterface(Ci.nsISelectionDisplay)
538             .QueryInterface(Ci.nsISelectionController),
539
540     /**
541      * Opens the appropriate context menu for *elem*.
542      *
543      * @param {Node} elem The context element.
544      */
545     openContextMenu: function openContextMenu(elem) {
546         document.popupNode = elem;
547         let menu = document.getElementById("contentAreaContextMenu");
548         menu.showPopup(elem, -1, -1, "context", "bottomleft", "topleft");
549     },
550
551     /**
552      * Saves a page link to disk.
553      *
554      * @param {HTMLAnchorElement} elem The page link to save.
555      */
556     saveLink: function saveLink(elem) {
557         let doc      = elem.ownerDocument;
558         let uri      = util.newURI(elem.href || elem.src, null, util.newURI(elem.baseURI));
559         let referrer = util.newURI(doc.documentURI, doc.characterSet);
560
561         try {
562             window.urlSecurityCheck(uri.spec, doc.nodePrincipal);
563
564             io.CommandFileMode("Save link: ", {
565                 onSubmit: function (path) {
566                     let file = io.File(path);
567                     if (file.exists() && file.isDirectory())
568                         file.append(Buffer.getDefaultNames(elem)[0][0]);
569
570                     try {
571                         if (!file.exists())
572                             file.create(File.NORMAL_FILE_TYPE, octal(644));
573                     }
574                     catch (e) {
575                         util.assert(false, _("save.invalidDestination", e.name));
576                     }
577
578                     buffer.saveURI(uri, file);
579                 },
580
581                 completer: function (context) completion.savePage(context, elem)
582             }).open();
583         }
584         catch (e) {
585             dactyl.echoerr(e);
586         }
587     },
588
589     /**
590      * Saves the contents of a URI to disk.
591      *
592      * @param {nsIURI} uri The URI to save
593      * @param {nsIFile} file The file into which to write the result.
594      */
595     saveURI: function saveURI(uri, file, callback, self) {
596         var persist = services.Persist();
597         persist.persistFlags = persist.PERSIST_FLAGS_FROM_CACHE
598                              | persist.PERSIST_FLAGS_REPLACE_EXISTING_FILES;
599
600         let downloadListener = new window.DownloadListener(window,
601                 services.Transfer(uri, services.io.newFileURI(file), "",
602                                   null, null, null, persist));
603
604         persist.progressListener = update(Object.create(downloadListener), {
605             onStateChange: function onStateChange(progress, request, flag, status) {
606                 if (callback && (flag & Ci.nsIWebProgressListener.STATE_STOP) && status == 0)
607                     dactyl.trapErrors(callback, self, uri, file, progress, request, flag, status);
608
609                 return onStateChange.superapply(this, arguments);
610             }
611         });
612
613         persist.saveURI(uri, null, null, null, null, file);
614     },
615
616     /**
617      * Scrolls the currently active element horizontally. See
618      * {@link Buffer.scrollHorizontal} for parameters.
619      */
620     scrollHorizontal: function scrollHorizontal(increment, number)
621         Buffer.scrollHorizontal(this.findScrollable(number, true), increment, number),
622
623     /**
624      * Scrolls the currently active element vertically. See
625      * {@link Buffer.scrollVertical} for parameters.
626      */
627     scrollVertical: function scrollVertical(increment, number)
628         Buffer.scrollVertical(this.findScrollable(number, false), increment, number),
629
630     /**
631      * Scrolls the currently active element to the given horizontal and
632      * vertical percentages. See {@link Buffer.scrollToPercent} for
633      * parameters.
634      */
635     scrollToPercent: function scrollToPercent(horizontal, vertical)
636         Buffer.scrollToPercent(this.findScrollable(0, vertical == null), horizontal, vertical),
637
638     _scrollByScrollSize: function _scrollByScrollSize(count, direction) {
639         if (count > 0)
640             options["scroll"] = count;
641         this.scrollByScrollSize(direction);
642     },
643
644     /**
645      * Scrolls the buffer vertically 'scroll' lines.
646      *
647      * @param {boolean} direction The direction to scroll. If true then
648      *     scroll up and if false scroll down.
649      * @param {number} count The multiple of 'scroll' lines to scroll.
650      * @optional
651      */
652     scrollByScrollSize: function scrollByScrollSize(direction, count) {
653         direction = direction ? 1 : -1;
654         count = count || 1;
655
656         if (options["scroll"] > 0)
657             this.scrollVertical("lines", options["scroll"] * direction);
658         else
659             this.scrollVertical("pages", direction / 2);
660     },
661
662     /**
663      * Find the best candidate scrollable element for the given
664      * direction and orientation.
665      *
666      * @param {number} dir The direction in which the element must be
667      *   able to scroll. Negative numbers represent up or left, while
668      *   positive numbers represent down or right.
669      * @param {boolean} horizontal If true, look for horizontally
670      *   scrollable elements, otherwise look for vertically scrollable
671      *   elements.
672      */
673     findScrollable: function findScrollable(dir, horizontal) {
674         function find(elem) {
675             while (!(elem instanceof Element) && elem.parentNode)
676                 elem = elem.parentNode;
677             for (; elem && elem.parentNode instanceof Element; elem = elem.parentNode)
678                 if (Buffer.isScrollable(elem, dir, horizontal))
679                     break;
680             return elem;
681         }
682
683         try {
684             var elem = this.focusedFrame.document.activeElement;
685             if (elem == elem.ownerDocument.body)
686                 elem = null;
687         }
688         catch (e) {}
689
690         try {
691             var sel = this.focusedFrame.getSelection();
692         }
693         catch (e) {}
694         if (!elem && sel && sel.rangeCount)
695             elem = sel.getRangeAt(0).startContainer;
696         if (elem)
697             elem = find(elem);
698
699         if (!(elem instanceof Element)) {
700             let doc = this.findScrollableWindow().document;
701             elem = find(doc.body || doc.getElementsByTagName("body")[0] ||
702                         doc.documentElement);
703         }
704         let doc = this.focusedFrame.document;
705         return elem || doc.body || doc.documentElement;
706     },
707
708     /**
709      * Find the best candidate scrollable frame in the current buffer.
710      */
711     findScrollableWindow: function findScrollableWindow() {
712         win = window.document.commandDispatcher.focusedWindow;
713         if (win && (win.scrollMaxX > 0 || win.scrollMaxY > 0))
714             return win;
715
716         let win = this.focusedFrame;
717         if (win && (win.scrollMaxX > 0 || win.scrollMaxY > 0))
718             return win;
719
720         win = content;
721         if (win.scrollMaxX > 0 || win.scrollMaxY > 0)
722             return win;
723
724         for (let frame in array.iterValues(win.frames))
725             if (frame.scrollMaxX > 0 || frame.scrollMaxY > 0)
726                 return frame;
727
728         return win;
729     },
730
731     // TODO: allow callback for filtering out unwanted frames? User defined?
732     /**
733      * Shifts the focus to another frame within the buffer. Each buffer
734      * contains at least one frame.
735      *
736      * @param {number} count The number of frames to skip through.  A negative
737      *     count skips backwards.
738      */
739     shiftFrameFocus: function shiftFrameFocus(count) {
740         if (!(content.document instanceof HTMLDocument))
741             return;
742
743         let frames = this.allFrames();
744
745         if (frames.length == 0) // currently top is always included
746             return;
747
748         // remove all hidden frames
749         frames = frames.filter(function (frame) !(frame.document.body instanceof HTMLFrameSetElement))
750                        .filter(function (frame) !frame.frameElement ||
751             let (rect = frame.frameElement.getBoundingClientRect())
752                 rect.width && rect.height);
753
754         // find the currently focused frame index
755         let current = Math.max(0, frames.indexOf(this.focusedFrame));
756
757         // calculate the next frame to focus
758         let next = current + count;
759         if (next < 0 || next >= frames.length)
760             dactyl.beep();
761         next = Math.constrain(next, 0, frames.length - 1);
762
763         // focus next frame and scroll into view
764         dactyl.focus(frames[next]);
765         if (frames[next] != content)
766             frames[next].frameElement.scrollIntoView(false);
767
768         // add the frame indicator
769         let doc = frames[next].document;
770         let indicator = util.xmlToDom(<div highlight="FrameIndicator"/>, doc);
771         (doc.body || doc.documentElement || doc).appendChild(indicator);
772
773         util.timeout(function () { doc.body.removeChild(indicator); }, 500);
774
775         // Doesn't unattach
776         //doc.body.setAttributeNS(NS.uri, "activeframe", "true");
777         //util.timeout(function () { doc.body.removeAttributeNS(NS.uri, "activeframe"); }, 500);
778     },
779
780     // similar to pageInfo
781     // TODO: print more useful information, just like the DOM inspector
782     /**
783      * Displays information about the specified element.
784      *
785      * @param {Node} elem The element to query.
786      */
787     showElementInfo: function showElementInfo(elem) {
788         dactyl.echo(<>Element:<br/>{util.objectToString(elem, true)}</>, commandline.FORCE_MULTILINE);
789     },
790
791     /**
792      * Displays information about the current buffer.
793      *
794      * @param {boolean} verbose Display more verbose information.
795      * @param {string} sections A string limiting the displayed sections.
796      * @default The value of 'pageinfo'.
797      */
798     showPageInfo: function showPageInfo(verbose, sections) {
799         // Ctrl-g single line output
800         if (!verbose) {
801             let file = content.location.pathname.split("/").pop() || "[No Name]";
802             let title = content.document.title || "[No Title]";
803
804             let info = template.map("gf",
805                 function (opt) template.map(buffer.pageInfo[opt].action(), util.identity, ", "),
806                 ", ");
807
808             if (bookmarkcache.isBookmarked(this.URL))
809                 info += ", bookmarked";
810
811             let pageInfoText = <>{file.quote()} [{info}] {title}</>;
812             dactyl.echo(pageInfoText, commandline.FORCE_SINGLELINE);
813             return;
814         }
815
816         let list = template.map(sections || options["pageinfo"], function (option) {
817             let { action, title } = buffer.pageInfo[option];
818             return template.table(title, action(true));
819         }, <br/>);
820         dactyl.echo(list, commandline.FORCE_MULTILINE);
821     },
822
823     /**
824      * Stops loading and animations in the current content.
825      */
826     stop: function stop() {
827         if (config.stop)
828             config.stop();
829         else
830             config.browser.mCurrentBrowser.stop();
831     },
832
833     /**
834      * Opens a viewer to inspect the source of the currently selected
835      * range.
836      */
837     viewSelectionSource: function viewSelectionSource() {
838         // copied (and tuned somewhat) from browser.jar -> nsContextMenu.js
839         let win = document.commandDispatcher.focusedWindow;
840         if (win == window)
841             win = this.focusedFrame;
842
843         let charset = win ? "charset=" + win.document.characterSet : null;
844
845         window.openDialog("chrome://global/content/viewPartialSource.xul",
846                           "_blank", "scrollbars,resizable,chrome,dialog=no",
847                           null, charset, win.getSelection(), "selection");
848     },
849
850     /**
851      * Opens a viewer to inspect the source of the current buffer or the
852      * specified *url*. Either the default viewer or the configured external
853      * editor is used.
854      *
855      * @param {string} url The URL of the source.
856      * @default The current buffer.
857      * @param {boolean} useExternalEditor View the source in the external editor.
858      */
859     viewSource: function viewSource(url, useExternalEditor) {
860         let doc = this.focusedFrame.document;
861
862         if (isArray(url)) {
863             if (options.get("editor").has("line"))
864                 this.viewSourceExternally(url[0] || doc, url[1]);
865             else
866                 window.openDialog("chrome://global/content/viewSource.xul",
867                                   "_blank", "all,dialog=no",
868                                   url[0], null, null, url[1]);
869         }
870         else {
871             if (useExternalEditor)
872                 this.viewSourceExternally(url || doc);
873             else {
874                 url = url || doc.location.href;
875                 const PREFIX = "view-source:";
876                 if (url.indexOf(PREFIX) == 0)
877                     url = url.substr(PREFIX.length);
878                 else
879                     url = PREFIX + url;
880
881                 let sh = history.session;
882                 if (sh[sh.index].URI.spec == url)
883                     window.getWebNavigation().gotoIndex(sh.index);
884                 else
885                     dactyl.open(url, { hide: true });
886             }
887         }
888     },
889
890     /**
891      * Launches an editor to view the source of the given document. The
892      * contents of the document are saved to a temporary local file and
893      * removed when the editor returns. This function returns
894      * immediately.
895      *
896      * @param {Document} doc The document to view.
897      */
898     viewSourceExternally: Class("viewSourceExternally",
899         XPCOM([Ci.nsIWebProgressListener, Ci.nsISupportsWeakReference]), {
900         init: function init(doc, callback) {
901             this.callback = callable(callback) ? callback :
902                 function (file, temp) {
903                     editor.editFileExternally({ file: file.path, line: callback },
904                                               function () { temp && file.remove(false); });
905                     return true;
906                 };
907
908             let uri = isString(doc) ? util.newURI(doc) : util.newURI(doc.location.href);
909
910             if (!isString(doc))
911                 return io.withTempFiles(function (temp) {
912                     let encoder = services.HtmlEncoder();
913                     encoder.init(doc, "text/unicode", encoder.OutputRaw|encoder.OutputPreformatted);
914                     temp.write(encoder.encodeToString(), ">");
915                     return this.callback(temp, true);
916                 }, this, true);
917
918             let file = util.getFile(uri);
919             if (file)
920                 this.callback(file, false);
921             else {
922                 this.file = io.createTempFile();
923                 var persist = services.Persist();
924                 persist.persistFlags = persist.PERSIST_FLAGS_REPLACE_EXISTING_FILES;
925                 persist.progressListener = this;
926                 persist.saveURI(uri, null, null, null, null, this.file);
927             }
928             return null;
929         },
930
931         onStateChange: function onStateChange(progress, request, flag, status) {
932             if ((flag & this.STATE_STOP) && status == 0) {
933                 try {
934                     var ok = this.callback(this.file, true);
935                 }
936                 finally {
937                     if (ok !== true)
938                         this.file.remove(false);
939                 }
940             }
941             return 0;
942         }
943     }),
944
945     /**
946      * Increases the zoom level of the current buffer.
947      *
948      * @param {number} steps The number of zoom levels to jump.
949      * @param {boolean} fullZoom Whether to use full zoom or text zoom.
950      */
951     zoomIn: function zoomIn(steps, fullZoom) {
952         this.bumpZoomLevel(steps, fullZoom);
953     },
954
955     /**
956      * Decreases the zoom level of the current buffer.
957      *
958      * @param {number} steps The number of zoom levels to jump.
959      * @param {boolean} fullZoom Whether to use full zoom or text zoom.
960      */
961     zoomOut: function zoomOut(steps, fullZoom) {
962         this.bumpZoomLevel(-steps, fullZoom);
963     },
964
965     /**
966      * Adjusts the page zoom of the current buffer to the given absolute
967      * value.
968      *
969      * @param {number} value The new zoom value as a possibly fractional
970      *   percentage of the page's natural size.
971      * @param {boolean} fullZoom If true, zoom all content of the page,
972      *   including raster images. If false, zoom only text. If omitted,
973      *   use the current zoom function. @optional
974      * @throws {FailedAssertion} if the given *value* is not within the
975      *   closed range [Buffer.ZOOM_MIN, Buffer.ZOOM_MAX].
976      */
977     setZoom: function setZoom(value, fullZoom) {
978         dactyl.assert(value >= Buffer.ZOOM_MIN || value <= Buffer.ZOOM_MAX,
979                       _("zoom.outOfRange", Buffer.ZOOM_MIN, Buffer.ZOOM_MAX));
980
981         if (fullZoom !== undefined)
982             ZoomManager.useFullZoom = fullZoom;
983         try {
984             ZoomManager.zoom = value / 100;
985         }
986         catch (e if e == Cr.NS_ERROR_ILLEGAL_VALUE) {
987             return dactyl.echoerr(_("zoom.illegal"));
988         }
989
990         if ("FullZoom" in window)
991             FullZoom._applySettingToPref();
992
993         statusline.updateZoomLevel(value, ZoomManager.useFullZoom);
994     },
995
996     /**
997      * Adjusts the page zoom of the current buffer relative to the
998      * current zoom level.
999      *
1000      * @param {number} steps The integral number of natural fractions by
1001      *   which to adjust the current page zoom. If positive, the zoom
1002      *   level is increased, if negative it is decreased.
1003      * @param {boolean} fullZoom If true, zoom all content of the page,
1004      *   including raster images. If false, zoom only text. If omitted,
1005      *   use the current zoom function. @optional
1006      * @throws {FailedAssertion} if the buffer's zoom level is already
1007      *  at its extreme in the given direction.
1008      */
1009     bumpZoomLevel: function bumpZoomLevel(steps, fullZoom) {
1010         if (fullZoom === undefined)
1011             fullZoom = ZoomManager.useFullZoom;
1012
1013         let values = ZoomManager.zoomValues;
1014         let cur = values.indexOf(ZoomManager.snap(ZoomManager.zoom));
1015         let i = Math.constrain(cur + steps, 0, values.length - 1);
1016
1017         dactyl.assert(i != cur || fullZoom != ZoomManager.useFullZoom);
1018
1019         this.setZoom(Math.round(values[i] * 100), fullZoom);
1020     },
1021
1022     getAllFrames: deprecated("buffer.allFrames", function getAllFrames() buffer.getAllFrames.apply(buffer, arguments)),
1023     scrollTop: deprecated("buffer.scrollToPercent", function scrollTop() buffer.scrollToPercent(null, 0)),
1024     scrollBottom: deprecated("buffer.scrollToPercent", function scrollBottom() buffer.scrollToPercent(null, 100)),
1025     scrollStart: deprecated("buffer.scrollToPercent", function scrollStart() buffer.scrollToPercent(0, null)),
1026     scrollEnd: deprecated("buffer.scrollToPercent", function scrollEnd() buffer.scrollToPercent(100, null)),
1027     scrollColumns: deprecated("buffer.scrollHorizontal", function scrollColumns(cols) buffer.scrollHorizontal("columns", cols)),
1028     scrollPages: deprecated("buffer.scrollHorizontal", function scrollPages(pages) buffer.scrollVertical("pages", pages)),
1029     scrollTo: deprecated("Buffer.scrollTo", function scrollTo(x, y) content.scrollTo(x, y)),
1030     textZoom: deprecated("buffer.zoomValue and buffer.fullZoom", function textZoom() config.browser.markupDocumentViewer.textZoom * 100)
1031 }, {
1032     PageInfo: Struct("PageInfo", "name", "title", "action")
1033                         .localize("title"),
1034
1035     ZOOM_MIN: Class.memoize(function () prefs.get("zoom.minPercent")),
1036     ZOOM_MAX: Class.memoize(function () prefs.get("zoom.maxPercent")),
1037
1038     setZoom: deprecated("buffer.setZoom", function setZoom() buffer.setZoom.apply(buffer, arguments)),
1039     bumpZoomLevel: deprecated("buffer.bumpZoomLevel", function bumpZoomLevel() buffer.bumpZoomLevel.apply(buffer, arguments)),
1040
1041     /**
1042      * Returns the currently selected word in *win*. If the selection is
1043      * null, it tries to guess the word that the caret is positioned in.
1044      *
1045      * @returns {string}
1046      */
1047     currentWord: function currentWord(win) {
1048         let selection = win.getSelection();
1049         if (selection.rangeCount == 0)
1050             return "";
1051
1052         let range = selection.getRangeAt(0).cloneRange();
1053         if (range.collapsed) {
1054             let re = options.get("iskeyword").regexp;
1055             Editor.extendRange(range, true,  re, true);
1056             Editor.extendRange(range, false, re, true);
1057         }
1058         return util.domToString(range);
1059     },
1060
1061     getDefaultNames: function getDefaultNames(node) {
1062         let url = node.href || node.src || node.documentURI;
1063         let currExt = url.replace(/^.*?(?:\.([a-z0-9]+))?$/i, "$1").toLowerCase();
1064
1065         if (isinstance(node, [Document, HTMLImageElement])) {
1066             let type = node.contentType || node.QueryInterface(Ci.nsIImageLoadingContent)
1067                                                .getRequest(0).mimeType;
1068
1069             if (type === "text/plain")
1070                 var ext = "." + (currExt || "txt");
1071             else
1072                 ext = "." + services.mime.getPrimaryExtension(type, currExt);
1073         }
1074         else if (currExt)
1075             ext = "." + currExt;
1076         else
1077             ext = "";
1078         let re = ext ? RegExp("(\\." + currExt + ")?$", "i") : /$/;
1079
1080         var names = [];
1081         if (node.title)
1082             names.push([node.title, "Page Name"]);
1083
1084         if (node.alt)
1085             names.push([node.alt, "Alternate Text"]);
1086
1087         if (!isinstance(node, Document) && node.textContent)
1088             names.push([node.textContent, "Link Text"]);
1089
1090         names.push([decodeURIComponent(url.replace(/.*?([^\/]*)\/*$/, "$1")), "File Name"]);
1091
1092         return names.filter(function ([leaf, title]) leaf)
1093                     .map(function ([leaf, title]) [leaf.replace(util.OS.illegalCharacters, encodeURIComponent)
1094                                                        .replace(re, ext), title]);
1095     },
1096
1097     findScrollableWindow: deprecated("buffer.findScrollableWindow", function findScrollableWindow() buffer.findScrollableWindow.apply(buffer, arguments)),
1098     findScrollable: deprecated("buffer.findScrollable", function findScrollable() buffer.findScrollable.apply(buffer, arguments)),
1099
1100     isScrollable: function isScrollable(elem, dir, horizontal) {
1101         let pos = "scrollTop", size = "clientHeight", max = "scrollHeight", layoutSize = "offsetHeight",
1102             overflow = "overflowX", border1 = "borderTopWidth", border2 = "borderBottomWidth";
1103         if (horizontal)
1104             pos = "scrollLeft", size = "clientWidth", max = "scrollWidth", layoutSize = "offsetWidth",
1105             overflow = "overflowX", border1 = "borderLeftWidth", border2 = "borderRightWidth";
1106
1107         let style = util.computedStyle(elem);
1108         let borderSize = Math.round(parseFloat(style[border1]) + parseFloat(style[border2]));
1109         let realSize = elem[size];
1110         // Stupid Gecko eccentricities. May fail for quirks mode documents.
1111         if (elem[size] + borderSize == elem[max] || elem[size] == 0) // Stupid, fallible heuristic.
1112             return false;
1113         if (style[overflow] == "hidden")
1114             realSize += borderSize;
1115         return dir < 0 && elem[pos] > 0 || dir > 0 && elem[pos] + realSize < elem[max] || !dir && realSize < elem[max];
1116     },
1117
1118     /**
1119      * Scroll the contents of the given element to the absolute *left*
1120      * and *top* pixel offsets.
1121      *
1122      * @param {Element} elem The element to scroll.
1123      * @param {number|null} left The left absolute pixel offset. If
1124      *   null, to not alter the horizontal scroll offset.
1125      * @param {number|null} top The top absolute pixel offset. If
1126      *   null, to not alter the vertical scroll offset.
1127      */
1128     scrollTo: function scrollTo(elem, left, top) {
1129         // Temporary hack. Should be done better.
1130         if (elem.ownerDocument == buffer.focusedFrame.document)
1131             marks.add("'");
1132         if (left != null)
1133             elem.scrollLeft = left;
1134         if (top != null)
1135             elem.scrollTop = top;
1136     },
1137
1138     /**
1139      * Scrolls the currently given element horizontally.
1140      *
1141      * @param {Element} elem The element to scroll.
1142      * @param {string} increment The increment by which to scroll.
1143      *   Possible values are: "columns", "pages"
1144      * @param {number} number The possibly fractional number of
1145      *   increments to scroll. Positive values scroll to the right while
1146      *   negative values scroll to the left.
1147      * @throws {FailedAssertion} if scrolling is not possible in the
1148      *   given direction.
1149      */
1150     scrollHorizontal: function scrollHorizontal(elem, increment, number) {
1151         let fontSize = parseInt(util.computedStyle(elem).fontSize);
1152         if (increment == "columns")
1153             increment = fontSize; // Good enough, I suppose.
1154         else if (increment == "pages")
1155             increment = elem.clientWidth - fontSize;
1156         else
1157             throw Error();
1158
1159         let left = elem.dactylScrollDestX !== undefined ? elem.dactylScrollDestX : elem.scrollLeft;
1160         elem.dactylScrollDestX = undefined;
1161
1162         dactyl.assert(number < 0 ? left > 0 : left < elem.scrollWidth - elem.clientWidth);
1163         Buffer.scrollTo(elem, left + number * increment, null);
1164     },
1165
1166     /**
1167      * Scrolls the currently given element vertically.
1168      *
1169      * @param {Element} elem The element to scroll.
1170      * @param {string} increment The increment by which to scroll.
1171      *   Possible values are: "lines", "pages"
1172      * @param {number} number The possibly fractional number of
1173      *   increments to scroll. Positive values scroll upward while
1174      *   negative values scroll downward.
1175      * @throws {FailedAssertion} if scrolling is not possible in the
1176      *   given direction.
1177      */
1178     scrollVertical: function scrollVertical(elem, increment, number) {
1179         let fontSize = parseInt(util.computedStyle(elem).fontSize);
1180         if (increment == "lines")
1181             increment = fontSize;
1182         else if (increment == "pages")
1183             increment = elem.clientHeight - fontSize;
1184         else
1185             throw Error();
1186
1187         let top = elem.dactylScrollDestY !== undefined ? elem.dactylScrollDestY : elem.scrollTop;
1188         elem.dactylScrollDestY = undefined;
1189
1190         dactyl.assert(number < 0 ? top > 0 : top < elem.scrollHeight - elem.clientHeight);
1191         Buffer.scrollTo(elem, null, top + number * increment);
1192     },
1193
1194     /**
1195      * Scrolls the currently active element to the given horizontal and
1196      * vertical percentages.
1197      *
1198      * @param {Element} elem The element to scroll.
1199      * @param {number|null} horizontal The possibly fractional
1200      *   percentage of the current viewport width to scroll to. If null,
1201      *   do not scroll horizontally.
1202      * @param {number|null} vertical The possibly fractional percentage
1203      *   of the current viewport height to scroll to. If null, do not
1204      *   scroll vertically.
1205      */
1206     scrollToPercent: function scrollToPercent(elem, horizontal, vertical) {
1207         Buffer.scrollTo(elem,
1208                         horizontal == null ? null
1209                                            : (elem.scrollWidth - elem.clientWidth) * (horizontal / 100),
1210                         vertical   == null ? null
1211                                            : (elem.scrollHeight - elem.clientHeight) * (vertical / 100));
1212     },
1213
1214     openUploadPrompt: function openUploadPrompt(elem) {
1215         io.CommandFileMode("Upload file: ", {
1216             onSubmit: function onSubmit(path) {
1217                 let file = io.File(path);
1218                 dactyl.assert(file.exists());
1219
1220                 elem.value = file.path;
1221                 events.dispatch(elem, events.create(elem.ownerDocument, "change", {}));
1222             }
1223         }).open(elem.value);
1224     }
1225 }, {
1226     commands: function initCommands(dactyl, modules, window) {
1227         commands.add(["frameo[nly]"],
1228             "Show only the current frame's page",
1229             function (args) {
1230                 dactyl.open(buffer.focusedFrame.location.href);
1231             },
1232             { argCount: "0" });
1233
1234         commands.add(["ha[rdcopy]"],
1235             "Print current document",
1236             function (args) {
1237                 let arg = args[0];
1238
1239                 // FIXME: arg handling is a bit of a mess, check for filename
1240                 dactyl.assert(!arg || arg[0] == ">" && !util.OS.isWindows,
1241                               _("error.trailing"));
1242
1243                 prefs.withContext(function () {
1244                     if (arg) {
1245                         prefs.set("print.print_to_file", "true");
1246                         prefs.set("print.print_to_filename", io.File(arg.substr(1)).path);
1247                         dactyl.echomsg(_("print.toFile", arg.substr(1)));
1248                     }
1249                     else
1250                         dactyl.echomsg(_("print.sending"));
1251
1252                     prefs.set("print.always_print_silent", args.bang);
1253                     prefs.set("print.show_print_progress", !args.bang);
1254
1255                     config.browser.contentWindow.print();
1256                 });
1257
1258                 if (arg)
1259                     dactyl.echomsg(_("print.printed", arg.substr(1)));
1260                 else
1261                     dactyl.echomsg(_("print.sent"));
1262             },
1263             {
1264                 argCount: "?",
1265                 bang: true,
1266                 literal: 0
1267             });
1268
1269         commands.add(["pa[geinfo]"],
1270             "Show various page information",
1271             function (args) {
1272                 let arg = args[0];
1273                 let opt = options.get("pageinfo");
1274
1275                 dactyl.assert(!arg || opt.validator(opt.parse(arg)),
1276                               _("error.invalidArgument", arg));
1277                 buffer.showPageInfo(true, arg);
1278             },
1279             {
1280                 argCount: "?",
1281                 completer: function (context) {
1282                     completion.optionValue(context, "pageinfo", "+", "");
1283                     context.title = ["Page Info"];
1284                 }
1285             });
1286
1287         commands.add(["pagest[yle]", "pas"],
1288             "Select the author style sheet to apply",
1289             function (args) {
1290                 let arg = args[0] || "";
1291
1292                 let titles = buffer.alternateStyleSheets.map(function (stylesheet) stylesheet.title);
1293
1294                 dactyl.assert(!arg || titles.indexOf(arg) >= 0,
1295                               _("error.invalidArgument", arg));
1296
1297                 if (options["usermode"])
1298                     options["usermode"] = false;
1299
1300                 window.stylesheetSwitchAll(buffer.focusedFrame, arg);
1301             },
1302             {
1303                 argCount: "?",
1304                 completer: function (context) completion.alternateStyleSheet(context),
1305                 literal: 0
1306             });
1307
1308         commands.add(["re[load]"],
1309             "Reload the current web page",
1310             function (args) { tabs.reload(config.browser.mCurrentTab, args.bang); },
1311             {
1312                 argCount: "0",
1313                 bang: true
1314             });
1315
1316         // TODO: we're prompted if download.useDownloadDir isn't set and no arg specified - intentional?
1317         commands.add(["sav[eas]", "w[rite]"],
1318             "Save current document to disk",
1319             function (args) {
1320                 let doc = content.document;
1321                 let chosenData = null;
1322                 let filename = args[0];
1323
1324                 let command = commandline.command;
1325                 if (filename) {
1326                     if (filename[0] == "!")
1327                         return buffer.viewSourceExternally(buffer.focusedFrame.document,
1328                             function (file) {
1329                                 let output = io.system(filename.substr(1), file);
1330                                 commandline.command = command;
1331                                 commandline.commandOutput(<span highlight="CmdOutput">{output}</span>);
1332                             });
1333
1334                     if (/^>>/.test(filename)) {
1335                         let file = io.File(filename.replace(/^>>\s*/, ""));
1336                         dactyl.assert(args.bang || file.exists() && file.isWritable(),
1337                                       _("io.notWriteable", file.path.quote()));
1338                         return buffer.viewSourceExternally(buffer.focusedFrame.document,
1339                             function (tmpFile) {
1340                                 try {
1341                                     file.write(tmpFile, ">>");
1342                                 }
1343                                 catch (e) {
1344                                     dactyl.echoerr(_("io.notWriteable", file.path.quote()));
1345                                 }
1346                             });
1347                     }
1348
1349                     let file = io.File(filename.replace(RegExp(File.PATH_SEP + "*$"), ""));
1350
1351                     if (filename.substr(-1) === File.PATH_SEP || file.exists() && file.isDirectory())
1352                         file.append(Buffer.getDefaultNames(doc)[0][0]);
1353
1354                     dactyl.assert(args.bang || !file.exists(), _("io.exists"));
1355
1356                     chosenData = { file: file, uri: util.newURI(doc.location.href) };
1357                 }
1358
1359                 // if browser.download.useDownloadDir = false then the "Save As"
1360                 // dialog is used with this as the default directory
1361                 // TODO: if we're going to do this shouldn't it be done in setCWD or the value restored?
1362                 prefs.set("browser.download.lastDir", io.cwd.path);
1363
1364                 try {
1365                     var contentDisposition = content.QueryInterface(Ci.nsIInterfaceRequestor)
1366                                                     .getInterface(Ci.nsIDOMWindowUtils)
1367                                                     .getDocumentMetadata("content-disposition");
1368                 }
1369                 catch (e) {}
1370
1371                 window.internalSave(doc.location.href, doc, null, contentDisposition,
1372                                     doc.contentType, false, null, chosenData,
1373                                     doc.referrer ? window.makeURI(doc.referrer) : null,
1374                                     true);
1375             },
1376             {
1377                 argCount: "?",
1378                 bang: true,
1379                 completer: function (context) {
1380                     if (context.filter[0] == "!")
1381                         return;
1382                     if (/^>>/.test(context.filter))
1383                         context.advance(/^>>\s*/.exec(context.filter)[0].length);
1384
1385                     completion.savePage(context, content.document);
1386                     context.fork("file", 0, completion, "file");
1387                 },
1388                 literal: 0
1389             });
1390
1391         commands.add(["st[op]"],
1392             "Stop loading the current web page",
1393             function () { buffer.stop(); },
1394             { argCount: "0" });
1395
1396         commands.add(["vie[wsource]"],
1397             "View source code of current document",
1398             function (args) { buffer.viewSource(args[0], args.bang); },
1399             {
1400                 argCount: "?",
1401                 bang: true,
1402                 completer: function (context) completion.url(context, "bhf")
1403             });
1404
1405         commands.add(["zo[om]"],
1406             "Set zoom value of current web page",
1407             function (args) {
1408                 let arg = args[0];
1409                 let level;
1410
1411                 if (!arg)
1412                     level = 100;
1413                 else if (/^\d+$/.test(arg))
1414                     level = parseInt(arg, 10);
1415                 else if (/^[+-]\d+$/.test(arg)) {
1416                     level = Math.round(buffer.zoomLevel + parseInt(arg, 10));
1417                     level = Math.constrain(level, Buffer.ZOOM_MIN, Buffer.ZOOM_MAX);
1418                 }
1419                 else
1420                     dactyl.assert(false, _("error.trailing"));
1421
1422                 buffer.setZoom(level, args.bang);
1423             },
1424             {
1425                 argCount: "?",
1426                 bang: true
1427             });
1428     },
1429     completion: function initCompletion(dactyl, modules, window) {
1430         completion.alternateStyleSheet = function alternateStylesheet(context) {
1431             context.title = ["Stylesheet", "Location"];
1432
1433             // unify split style sheets
1434             let styles = iter([s.title, []] for (s in values(buffer.alternateStyleSheets))).toObject();
1435
1436             buffer.alternateStyleSheets.forEach(function (style) {
1437                 styles[style.title].push(style.href || "inline");
1438             });
1439
1440             context.completions = [[title, href.join(", ")] for ([title, href] in Iterator(styles))];
1441         };
1442
1443         completion.buffer = function buffer(context) {
1444             let filter = context.filter.toLowerCase();
1445             let defItem = { parent: { getTitle: function () "" } };
1446             let tabGroups = {};
1447             tabs.getGroups();
1448             tabs.allTabs.forEach(function (tab, i) {
1449                 let group = (tab.tabItem || tab._tabViewTabItem || defItem).parent || defItem.parent;
1450                 if (!set.has(tabGroups, group.id))
1451                     tabGroups[group.id] = [group.getTitle(), []];
1452                 group = tabGroups[group.id];
1453                 group[1].push([i, tab.linkedBrowser]);
1454             });
1455
1456             context.pushProcessor(0, function (item, text, next) <>
1457                 <span highlight="Indicator" style="display: inline-block;">{item.indicator}</span>
1458                 { next.call(this, item, text) }
1459             </>);
1460             context.process[1] = function (item, text) template.bookmarkDescription(item, template.highlightFilter(text, this.filter));
1461
1462             context.anchored = false;
1463             context.keys = {
1464                 text: "text",
1465                 description: "url",
1466                 indicator: function (item) item.tab === tabs.getTab()  ? "%" :
1467                                            item.tab === tabs.alternate ? "#" : " ",
1468                 icon: "icon",
1469                 id: "id",
1470                 command: function () "tabs.select"
1471             };
1472             context.compare = CompletionContext.Sort.number;
1473             context.filters = [CompletionContext.Filter.textDescription];
1474
1475             for (let [id, vals] in Iterator(tabGroups))
1476                 context.fork(id, 0, this, function (context, [name, browsers]) {
1477                     context.title = [name || "Buffers"];
1478                     context.generate = function ()
1479                         Array.map(browsers, function ([i, browser]) {
1480                             let indicator = " ";
1481                             if (i == tabs.index())
1482                                 indicator = "%";
1483                             else if (i == tabs.index(tabs.alternate))
1484                                 indicator = "#";
1485
1486                             let tab = tabs.getTab(i);
1487                             let url = browser.contentDocument.location.href;
1488                             i = i + 1;
1489
1490                             return {
1491                                 text: [i + ": " + (tab.label || "(Untitled)"), i + ": " + url],
1492                                 tab: tab,
1493                                 id: i - 1,
1494                                 url: url,
1495                                 icon: tab.image || DEFAULT_FAVICON
1496                             };
1497                         });
1498                 }, vals);
1499         };
1500
1501         completion.savePage = function savePage(context, node) {
1502             context.fork("generated", context.filter.replace(/[^/]*$/, "").length,
1503                          this, function (context) {
1504                 context.completions = Buffer.getDefaultNames(node);
1505             });
1506         };
1507     },
1508     events: function initEvents(dactyl, modules, window) {
1509         events.listen(config.browser, "scroll", buffer.closure._updateBufferPosition, false);
1510     },
1511     mappings: function initMappings(dactyl, modules, window) {
1512         mappings.add([modes.NORMAL],
1513             ["y", "<yank-location>"], "Yank current location to the clipboard",
1514             function () { dactyl.clipboardWrite(buffer.uri.spec, true); });
1515
1516         mappings.add([modes.NORMAL],
1517             ["<C-a>"], "Increment last number in URL",
1518             function (args) { buffer.incrementURL(Math.max(args.count, 1)); },
1519             { count: true });
1520
1521         mappings.add([modes.NORMAL],
1522             ["<C-x>"], "Decrement last number in URL",
1523             function (args) { buffer.incrementURL(-Math.max(args.count, 1)); },
1524             { count: true });
1525
1526         mappings.add([modes.NORMAL], ["gu"],
1527             "Go to parent directory",
1528             function (args) { buffer.climbUrlPath(Math.max(args.count, 1)); },
1529             { count: true });
1530
1531         mappings.add([modes.NORMAL], ["gU"],
1532             "Go to the root of the website",
1533             function () { buffer.climbUrlPath(-1); });
1534
1535         mappings.add([modes.COMMAND], [".", "<repeat-key>"],
1536             "Repeat the last key event",
1537             function (args) {
1538                 if (mappings.repeat) {
1539                     for (let i in util.interruptibleRange(0, Math.max(args.count, 1), 100))
1540                         mappings.repeat();
1541                 }
1542             },
1543             { count: true });
1544
1545         mappings.add([modes.COMMAND], ["i", "<Insert>"],
1546             "Start caret mode",
1547             function () { modes.push(modes.CARET); });
1548
1549         mappings.add([modes.COMMAND], ["<C-c>"],
1550             "Stop loading the current web page",
1551             function () { ex.stop(); });
1552
1553         // scrolling
1554         mappings.add([modes.COMMAND], ["j", "<Down>", "<C-e>", "<scroll-down-line>"],
1555             "Scroll document down",
1556             function (args) { buffer.scrollVertical("lines", Math.max(args.count, 1)); },
1557             { count: true });
1558
1559         mappings.add([modes.COMMAND], ["k", "<Up>", "<C-y>", "<scroll-up-line>"],
1560             "Scroll document up",
1561             function (args) { buffer.scrollVertical("lines", -Math.max(args.count, 1)); },
1562             { count: true });
1563
1564         mappings.add([modes.COMMAND], dactyl.has("mail") ? ["h", "<scroll-left-column>"] : ["h", "<Left>", "<scroll-left-column>"],
1565             "Scroll document to the left",
1566             function (args) { buffer.scrollHorizontal("columns", -Math.max(args.count, 1)); },
1567             { count: true });
1568
1569         mappings.add([modes.COMMAND], dactyl.has("mail") ? ["l", "<scroll-right-column>"] : ["l", "<Right>", "<scroll-right-column>"],
1570             "Scroll document to the right",
1571             function (args) { buffer.scrollHorizontal("columns", Math.max(args.count, 1)); },
1572             { count: true });
1573
1574         mappings.add([modes.COMMAND], ["0", "^", "<scroll-begin>"],
1575             "Scroll to the absolute left of the document",
1576             function () { buffer.scrollToPercent(0, null); });
1577
1578         mappings.add([modes.COMMAND], ["$", "<scroll-end>"],
1579             "Scroll to the absolute right of the document",
1580             function () { buffer.scrollToPercent(100, null); });
1581
1582         mappings.add([modes.COMMAND], ["gg", "<Home>"],
1583             "Go to the top of the document",
1584             function (args) { buffer.scrollToPercent(null, args.count != null ? args.count : 0); },
1585             { count: true });
1586
1587         mappings.add([modes.COMMAND], ["G", "<End>"],
1588             "Go to the end of the document",
1589             function (args) { buffer.scrollToPercent(null, args.count != null ? args.count : 100); },
1590             { count: true });
1591
1592         mappings.add([modes.COMMAND], ["%", "<scroll-percent>"],
1593             "Scroll to {count} percent of the document",
1594             function (args) {
1595                 dactyl.assert(args.count > 0 && args.count <= 100);
1596                 buffer.scrollToPercent(null, args.count);
1597             },
1598             { count: true });
1599
1600         mappings.add([modes.COMMAND], ["<C-d>", "<scroll-down>"],
1601             "Scroll window downwards in the buffer",
1602             function (args) { buffer._scrollByScrollSize(args.count, true); },
1603             { count: true });
1604
1605         mappings.add([modes.COMMAND], ["<C-u>", "<scroll-up>"],
1606             "Scroll window upwards in the buffer",
1607             function (args) { buffer._scrollByScrollSize(args.count, false); },
1608             { count: true });
1609
1610         mappings.add([modes.COMMAND], ["<C-b>", "<PageUp>", "<S-Space>", "<scroll-page-up>"],
1611             "Scroll up a full page",
1612             function (args) { buffer.scrollVertical("pages", -Math.max(args.count, 1)); },
1613             { count: true });
1614
1615         mappings.add([modes.COMMAND], ["<C-f>", "<PageDown>", "<Space>", "<scroll-page-down>"],
1616             "Scroll down a full page",
1617             function (args) { buffer.scrollVertical("pages", Math.max(args.count, 1)); },
1618             { count: true });
1619
1620         mappings.add([modes.COMMAND], ["]f", "<previous-frame>"],
1621             "Focus next frame",
1622             function (args) { buffer.shiftFrameFocus(Math.max(args.count, 1)); },
1623             { count: true });
1624
1625         mappings.add([modes.COMMAND], ["[f", "<next-frame>"],
1626             "Focus previous frame",
1627             function (args) { buffer.shiftFrameFocus(-Math.max(args.count, 1)); },
1628             { count: true });
1629
1630         mappings.add([modes.COMMAND], ["]]", "<next-page>"],
1631             "Follow the link labeled 'next' or '>' if it exists",
1632             function (args) {
1633                 buffer.findLink("next", options["nextpattern"], (args.count || 1) - 1, true);
1634             },
1635             { count: true });
1636
1637         mappings.add([modes.COMMAND], ["[[", "<previous-page>"],
1638             "Follow the link labeled 'prev', 'previous' or '<' if it exists",
1639             function (args) {
1640                 buffer.findLink("previous", options["previouspattern"], (args.count || 1) - 1, true);
1641             },
1642             { count: true });
1643
1644         mappings.add([modes.COMMAND], ["gf", "<view-source>"],
1645             "Toggle between rendered and source view",
1646             function () { buffer.viewSource(null, false); });
1647
1648         mappings.add([modes.COMMAND], ["gF", "<view-source-externally>"],
1649             "View source with an external editor",
1650             function () { buffer.viewSource(null, true); });
1651
1652         mappings.add([modes.COMMAND], ["gi", "<focus-input>"],
1653             "Focus last used input field",
1654             function (args) {
1655                 let elem = buffer.lastInputField;
1656
1657                 if (args.count >= 1 || !elem || !events.isContentNode(elem)) {
1658                     let xpath = ["frame", "iframe", "input", "textarea[not(@disabled) and not(@readonly)]"];
1659
1660                     let frames = buffer.allFrames(null, true);
1661
1662                     let elements = array.flatten(frames.map(function (win) [m for (m in util.evaluateXPath(xpath, win.document))]))
1663                                         .filter(function (elem) {
1664                         if (isinstance(elem, [HTMLFrameElement, HTMLIFrameElement]))
1665                             return Editor.getEditor(elem.contentWindow);
1666
1667                         if (elem.readOnly || elem instanceof HTMLInputElement && !set.has(util.editableInputs, elem.type))
1668                             return false;
1669
1670                         let computedStyle = util.computedStyle(elem);
1671                         let rect = elem.getBoundingClientRect();
1672                         return computedStyle.visibility != "hidden" && computedStyle.display != "none" &&
1673                             computedStyle.MozUserFocus != "ignore" && rect.width && rect.height;
1674                     });
1675
1676                     dactyl.assert(elements.length > 0);
1677                     elem = elements[Math.constrain(args.count, 1, elements.length) - 1];
1678                 }
1679                 buffer.focusElement(elem);
1680                 util.scrollIntoView(elem);
1681             },
1682             { count: true });
1683
1684         mappings.add([modes.COMMAND], ["gP"],
1685             "Open (]put) a URL based on the current clipboard contents in a new buffer",
1686             function () {
1687                 let url = dactyl.clipboardRead();
1688                 dactyl.assert(url, _("error.clipboardEmpty"));
1689                 dactyl.open(url, { from: "paste", where: dactyl.NEW_TAB, background: true });
1690             });
1691
1692         mappings.add([modes.COMMAND], ["p", "<MiddleMouse>", "<open-clipboard-url>"],
1693             "Open (put) a URL based on the current clipboard contents in the current buffer",
1694             function () {
1695                 let url = dactyl.clipboardRead();
1696                 dactyl.assert(url, _("error.clipboardEmpty"));
1697                 dactyl.open(url);
1698             });
1699
1700         mappings.add([modes.COMMAND], ["P", "<tab-open-clipboard-url>"],
1701             "Open (put) a URL based on the current clipboard contents in a new buffer",
1702             function () {
1703                 let url = dactyl.clipboardRead();
1704                 dactyl.assert(url, _("error.clipboardEmpty"));
1705                 dactyl.open(url, { from: "paste", where: dactyl.NEW_TAB });
1706             });
1707
1708         // reloading
1709         mappings.add([modes.COMMAND], ["r", "<reload>"],
1710             "Reload the current web page",
1711             function () { tabs.reload(tabs.getTab(), false); });
1712
1713         mappings.add([modes.COMMAND], ["R", "<full-reload>"],
1714             "Reload while skipping the cache",
1715             function () { tabs.reload(tabs.getTab(), true); });
1716
1717         // yanking
1718         mappings.add([modes.COMMAND], ["Y", "<yank-word>"],
1719             "Copy selected text or current word",
1720             function () {
1721                 let sel = buffer.currentWord;
1722                 dactyl.assert(sel);
1723                 dactyl.clipboardWrite(sel, true);
1724             });
1725
1726         // zooming
1727         mappings.add([modes.COMMAND], ["zi", "+", "<text-zoom-in>"],
1728             "Enlarge text zoom of current web page",
1729             function (args) { buffer.zoomIn(Math.max(args.count, 1), false); },
1730             { count: true });
1731
1732         mappings.add([modes.COMMAND], ["zm", "<text-zoom-more>"],
1733             "Enlarge text zoom of current web page by a larger amount",
1734             function (args) { buffer.zoomIn(Math.max(args.count, 1) * 3, false); },
1735             { count: true });
1736
1737         mappings.add([modes.COMMAND], ["zo", "-", "<text-zoom-out>"],
1738             "Reduce text zoom of current web page",
1739             function (args) { buffer.zoomOut(Math.max(args.count, 1), false); },
1740             { count: true });
1741
1742         mappings.add([modes.COMMAND], ["zr", "<text-zoom-reduce>"],
1743             "Reduce text zoom of current web page by a larger amount",
1744             function (args) { buffer.zoomOut(Math.max(args.count, 1) * 3, false); },
1745             { count: true });
1746
1747         mappings.add([modes.COMMAND], ["zz", "<text-zoom>"],
1748             "Set text zoom value of current web page",
1749             function (args) { buffer.setZoom(args.count > 1 ? args.count : 100, false); },
1750             { count: true });
1751
1752         mappings.add([modes.COMMAND], ["ZI", "zI", "<full-zoom-in>"],
1753             "Enlarge full zoom of current web page",
1754             function (args) { buffer.zoomIn(Math.max(args.count, 1), true); },
1755             { count: true });
1756
1757         mappings.add([modes.COMMAND], ["ZM", "zM", "<full-zoom-more>"],
1758             "Enlarge full zoom of current web page by a larger amount",
1759             function (args) { buffer.zoomIn(Math.max(args.count, 1) * 3, true); },
1760             { count: true });
1761
1762         mappings.add([modes.COMMAND], ["ZO", "zO", "<full-zoom-out>"],
1763             "Reduce full zoom of current web page",
1764             function (args) { buffer.zoomOut(Math.max(args.count, 1), true); },
1765             { count: true });
1766
1767         mappings.add([modes.COMMAND], ["ZR", "zR", "<full-zoom-reduce>"],
1768             "Reduce full zoom of current web page by a larger amount",
1769             function (args) { buffer.zoomOut(Math.max(args.count, 1) * 3, true); },
1770             { count: true });
1771
1772         mappings.add([modes.COMMAND], ["zZ", "<full-zoom>"],
1773             "Set full zoom value of current web page",
1774             function (args) { buffer.setZoom(args.count > 1 ? args.count : 100, true); },
1775             { count: true });
1776
1777         // page info
1778         mappings.add([modes.COMMAND], ["<C-g>", "<page-info>"],
1779             "Print the current file name",
1780             function () { buffer.showPageInfo(false); });
1781
1782         mappings.add([modes.COMMAND], ["g<C-g>", "<more-page-info>"],
1783             "Print file information",
1784             function () { buffer.showPageInfo(true); });
1785     },
1786     options: function initOptions(dactyl, modules, window) {
1787         options.add(["encoding", "enc"],
1788             "The current buffer's character encoding",
1789             "string", "UTF-8",
1790             {
1791                 scope: Option.SCOPE_LOCAL,
1792                 getter: function () config.browser.docShell.QueryInterface(Ci.nsIDocCharset).charset,
1793                 setter: function (val) {
1794                     if (options["encoding"] == val)
1795                         return val;
1796
1797                     // Stolen from browser.jar/content/browser/browser.js, more or less.
1798                     try {
1799                         config.browser.docShell.QueryInterface(Ci.nsIDocCharset).charset = val;
1800                         PlacesUtils.history.setCharsetForURI(getWebNavigation().currentURI, val);
1801                         getWebNavigation().reload(Ci.nsIWebNavigation.LOAD_FLAGS_CHARSET_CHANGE);
1802                     }
1803                     catch (e) { dactyl.reportError(e); }
1804                     return null;
1805                 },
1806                 completer: function (context) completion.charset(context)
1807             });
1808
1809         options.add(["iskeyword", "isk"],
1810             "Regular expression defining which characters constitute word characters",
1811             "string", '[^\\s.,!?:;/"\'^$%&?()[\\]{}<>#*+|=~_-]',
1812             {
1813                 setter: function (value) {
1814                     this.regexp = util.regexp(value);
1815                     return value;
1816                 },
1817                 validator: function (value) RegExp(value)
1818             });
1819
1820         options.add(["nextpattern"],
1821             "Patterns to use when guessing the next page in a document sequence",
1822             "regexplist", UTF8("'\\bnext\\b',^>$,^(>>|»)$,^(>|»),(>|»)$,'\\bmore\\b'"),
1823             { regexpFlags: "i" });
1824
1825         options.add(["previouspattern"],
1826             "Patterns to use when guessing the previous page in a document sequence",
1827             "regexplist", UTF8("'\\bprev|previous\\b',^<$,^(<<|«)$,^(<|«),(<|«)$"),
1828             { regexpFlags: "i" });
1829
1830         options.add(["pageinfo", "pa"],
1831             "Define which sections are shown by the :pageinfo command",
1832             "charlist", "gfm",
1833             { get values() values(buffer.pageInfo).toObject() });
1834
1835         options.add(["scroll", "scr"],
1836             "Number of lines to scroll with <C-u> and <C-d> commands",
1837             "number", 0,
1838             { validator: function (value) value >= 0 });
1839
1840         options.add(["showstatuslinks", "ssli"],
1841             "Where to show the destination of the link under the cursor",
1842             "string", "status",
1843             {
1844                 values: {
1845                     "": "Don't show link destinations",
1846                     "status": "Show link destinations in the status line",
1847                     "command": "Show link destinations in the command line"
1848                 }
1849             });
1850
1851         options.add(["usermode", "um"],
1852             "Show current website without styling defined by the author",
1853             "boolean", false,
1854             {
1855                 setter: function (value) config.browser.markupDocumentViewer.authorStyleDisabled = value,
1856                 getter: function () config.browser.markupDocumentViewer.authorStyleDisabled
1857             });
1858     }
1859 });
1860
1861 // vim: set fdm=marker sw=4 ts=4 et: