OLD | NEW |
| (Empty) |
1 // Copyright (c) 2011 The Chromium Authors. All rights reserved. | |
2 // Use of this source code is governed by a BSD-style license that can be | |
3 // found in the LICENSE file. | |
4 | |
5 /////////////////////////////////////////////////////////////////////////////// | |
6 // Globals: | |
7 var RESULTS_PER_PAGE = 150; | |
8 var MAX_SEARCH_DEPTH_MONTHS = 18; | |
9 | |
10 // Amount of time between pageviews that we consider a 'break' in browsing, | |
11 // measured in milliseconds. | |
12 var BROWSING_GAP_TIME = 15 * 60 * 1000; | |
13 | |
14 function $(o) {return document.getElementById(o);} | |
15 | |
16 function createElementWithClassName(type, className) { | |
17 var elm = document.createElement(type); | |
18 elm.className = className; | |
19 return elm; | |
20 } | |
21 | |
22 // Escapes a URI as appropriate for CSS. | |
23 function encodeURIForCSS(uri) { | |
24 // CSS uris need to have '(' and ')' escaped. | |
25 return uri.replace(/\(/g, "\\(").replace(/\)/g, "\\)"); | |
26 } | |
27 | |
28 // TODO(glen): Get rid of these global references, replace with a controller | |
29 // or just make the classes own more of the page. | |
30 var historyModel; | |
31 var historyView; | |
32 var localStrings; | |
33 var pageState; | |
34 var deleteQueue = []; | |
35 var selectionAnchor = -1; | |
36 var activePage = null; | |
37 | |
38 const MenuButton = cr.ui.MenuButton; | |
39 const Command = cr.ui.Command; | |
40 const Menu = cr.ui.Menu; | |
41 | |
42 function createDropDownBgImage(canvasName, colorSpec) { | |
43 var ctx = document.getCSSCanvasContext('2d', canvasName, 6, 4); | |
44 ctx.fillStyle = ctx.strokeStyle = colorSpec; | |
45 ctx.beginPath(); | |
46 ctx.moveTo(0, 0); | |
47 ctx.lineTo(6, 0); | |
48 ctx.lineTo(3, 3); | |
49 ctx.closePath(); | |
50 ctx.fill(); | |
51 ctx.stroke(); | |
52 return ctx; | |
53 } | |
54 | |
55 // Create the canvases to be used as the drop down button background images. | |
56 var arrow = createDropDownBgImage('drop-down-arrow', 'hsl(214, 91%, 85%)'); | |
57 var hoverArrow = createDropDownBgImage('drop-down-arrow-hover', '#6A86DE'); | |
58 var activeArrow = createDropDownBgImage('drop-down-arrow-active', 'white'); | |
59 | |
60 /////////////////////////////////////////////////////////////////////////////// | |
61 // Page: | |
62 /** | |
63 * Class to hold all the information about an entry in our model. | |
64 * @param {Object} result An object containing the page's data. | |
65 * @param {boolean} continued Whether this page is on the same day as the | |
66 * page before it | |
67 */ | |
68 function Page(result, continued, model, id) { | |
69 this.model_ = model; | |
70 this.title_ = result.title; | |
71 this.url_ = result.url; | |
72 this.domain_ = this.getDomainFromURL_(this.url_); | |
73 this.starred_ = result.starred; | |
74 this.snippet_ = result.snippet || ""; | |
75 this.id_ = id; | |
76 | |
77 this.changed = false; | |
78 | |
79 this.isRendered = false; | |
80 | |
81 // All the date information is public so that owners can compare properties of | |
82 // two items easily. | |
83 | |
84 // We get the time in seconds, but we want it in milliseconds. | |
85 this.time = new Date(result.time * 1000); | |
86 | |
87 // See comment in BrowsingHistoryHandler::QueryComplete - we won't always | |
88 // get all of these. | |
89 this.dateRelativeDay = result.dateRelativeDay || ""; | |
90 this.dateTimeOfDay = result.dateTimeOfDay || ""; | |
91 this.dateShort = result.dateShort || ""; | |
92 | |
93 // Whether this is the continuation of a previous day. | |
94 this.continued = continued; | |
95 } | |
96 | |
97 // Page, Public: -------------------------------------------------------------- | |
98 /** | |
99 * Returns a dom structure for a browse page result or a search page result. | |
100 * @param {boolean} Flag to indicate if result is a search result. | |
101 * @return {Element} The dom structure. | |
102 */ | |
103 Page.prototype.getResultDOM = function(searchResultFlag) { | |
104 var node = createElementWithClassName('li', 'entry'); | |
105 var time = createElementWithClassName('div', 'time'); | |
106 var entryBox = createElementWithClassName('label', 'entry-box'); | |
107 var domain = createElementWithClassName('div', 'domain'); | |
108 | |
109 var dropDown = createElementWithClassName('button', 'drop-down'); | |
110 dropDown.value = 'Open action menu'; | |
111 dropDown.title = localStrings.getString('actionMenuDescription'); | |
112 dropDown.setAttribute('menu', '#action-menu'); | |
113 cr.ui.decorate(dropDown, MenuButton); | |
114 | |
115 // Checkbox is always created, but only visible on hover & when checked. | |
116 var checkbox = document.createElement('input'); | |
117 checkbox.type = 'checkbox'; | |
118 checkbox.id = 'checkbox-' + this.id_; | |
119 checkbox.time = this.time.getTime(); | |
120 checkbox.addEventListener('click', checkboxClicked); | |
121 time.appendChild(checkbox); | |
122 | |
123 // Keep track of the drop down that triggered the menu, so we know | |
124 // which element to apply the command to. | |
125 // TODO(dubroy): Ideally we'd use 'activate', but MenuButton swallows it. | |
126 var self = this; | |
127 var setActivePage = function(e) { | |
128 activePage = self; | |
129 }; | |
130 dropDown.addEventListener('mousedown', setActivePage); | |
131 dropDown.addEventListener('focus', setActivePage); | |
132 | |
133 domain.style.backgroundImage = | |
134 'url(chrome://favicon/' + encodeURIForCSS(this.url_) + ')'; | |
135 domain.textContent = this.domain_; | |
136 | |
137 // Clicking anywhere in the entryBox will check/uncheck the checkbox. | |
138 entryBox.setAttribute('for', checkbox.id); | |
139 entryBox.addEventListener('mousedown', entryBoxMousedown, false); | |
140 | |
141 // Prevent clicks on the drop down from affecting the checkbox. | |
142 dropDown.addEventListener('click', function(e) { e.preventDefault(); }); | |
143 | |
144 // We use a wrapper div so that the entry contents will be shinkwrapped. | |
145 entryBox.appendChild(time); | |
146 entryBox.appendChild(domain); | |
147 entryBox.appendChild(this.getTitleDOM_()); | |
148 entryBox.appendChild(dropDown); | |
149 | |
150 // Let the entryBox be styled appropriately when it contains keyboard focus. | |
151 entryBox.addEventListener('focus', function() { | |
152 this.classList.add('contains-focus'); | |
153 }, true); | |
154 entryBox.addEventListener('blur', function() { | |
155 this.classList.remove('contains-focus'); | |
156 }, true); | |
157 | |
158 node.appendChild(entryBox); | |
159 | |
160 if (searchResultFlag) { | |
161 time.textContent = this.dateShort; | |
162 var snippet = createElementWithClassName('div', 'snippet'); | |
163 this.addHighlightedText_(snippet, | |
164 this.snippet_, | |
165 this.model_.getSearchText()); | |
166 node.appendChild(snippet); | |
167 } else { | |
168 time.appendChild(document.createTextNode(this.dateTimeOfDay)); | |
169 } | |
170 | |
171 if (typeof this.domNode_ != 'undefined') { | |
172 console.error('Already generated node for page.'); | |
173 } | |
174 this.domNode_ = node; | |
175 | |
176 return node; | |
177 }; | |
178 | |
179 // Page, private: ------------------------------------------------------------- | |
180 /** | |
181 * Extracts and returns the domain (and subdomains) from a URL. | |
182 * @param {string} The url | |
183 * @return (string) The domain. An empty string is returned if no domain can | |
184 * be found. | |
185 */ | |
186 Page.prototype.getDomainFromURL_ = function(url) { | |
187 var domain = url.replace(/^.+:\/\//, '').match(/[^/]+/); | |
188 return domain ? domain[0] : ''; | |
189 }; | |
190 | |
191 /** | |
192 * Add child text nodes to a node such that occurrences of the specified text is | |
193 * highlighted. | |
194 * @param {Node} node The node under which new text nodes will be made as | |
195 * children. | |
196 * @param {string} content Text to be added beneath |node| as one or more | |
197 * text nodes. | |
198 * @param {string} highlightText Occurences of this text inside |content| will | |
199 * be highlighted. | |
200 */ | |
201 Page.prototype.addHighlightedText_ = function(node, content, highlightText) { | |
202 var i = 0; | |
203 if (highlightText) { | |
204 var re = new RegExp(Page.pregQuote_(highlightText), 'gim'); | |
205 var match; | |
206 while (match = re.exec(content)) { | |
207 if (match.index > i) | |
208 node.appendChild(document.createTextNode(content.slice(i, | |
209 match.index))); | |
210 i = re.lastIndex; | |
211 // Mark the highlighted text in bold. | |
212 var b = document.createElement('b'); | |
213 b.textContent = content.substring(match.index, i); | |
214 node.appendChild(b); | |
215 } | |
216 } | |
217 if (i < content.length) | |
218 node.appendChild(document.createTextNode(content.slice(i))); | |
219 }; | |
220 | |
221 /** | |
222 * @return {DOMObject} DOM representation for the title block. | |
223 */ | |
224 Page.prototype.getTitleDOM_ = function() { | |
225 var node = createElementWithClassName('div', 'title'); | |
226 var link = document.createElement('a'); | |
227 link.href = this.url_; | |
228 link.id = "id-" + this.id_; | |
229 | |
230 // Add a tooltip, since it might be ellipsized. | |
231 // TODO(dubroy): Find a way to show the tooltip only when necessary. | |
232 link.title = this.title_; | |
233 | |
234 this.addHighlightedText_(link, this.title_, this.model_.getSearchText()); | |
235 node.appendChild(link); | |
236 | |
237 if (this.starred_) { | |
238 node.className += ' starred'; | |
239 node.appendChild(createElementWithClassName('div', 'starred')); | |
240 } | |
241 | |
242 return node; | |
243 }; | |
244 | |
245 /** | |
246 * Launch a search for more history entries from the same domain. | |
247 */ | |
248 Page.prototype.showMoreFromSite_ = function() { | |
249 setSearch(this.domain_); | |
250 }; | |
251 | |
252 /** | |
253 * Remove a single entry from the history. | |
254 */ | |
255 Page.prototype.removeFromHistory_ = function() { | |
256 var self = this; | |
257 var onSuccessCallback = function() { | |
258 removeEntryFromView(self.domNode_); | |
259 }; | |
260 queueURLsForDeletion(this.time, [this.url_], onSuccessCallback); | |
261 deleteNextInQueue(); | |
262 }; | |
263 | |
264 | |
265 // Page, private, static: ----------------------------------------------------- | |
266 | |
267 /** | |
268 * Quote a string so it can be used in a regular expression. | |
269 * @param {string} str The source string | |
270 * @return {string} The escaped string | |
271 */ | |
272 Page.pregQuote_ = function(str) { | |
273 return str.replace(/([\\\.\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:])/g, "\\$1"); | |
274 }; | |
275 | |
276 /////////////////////////////////////////////////////////////////////////////// | |
277 // HistoryModel: | |
278 /** | |
279 * Global container for history data. Future optimizations might include | |
280 * allowing the creation of a HistoryModel for each search string, allowing | |
281 * quick flips back and forth between results. | |
282 * | |
283 * The history model is based around pages, and only fetching the data to | |
284 * fill the currently requested page. This is somewhat dependent on the view, | |
285 * and so future work may wish to change history model to operate on | |
286 * timeframe (day or week) based containers. | |
287 */ | |
288 function HistoryModel() { | |
289 this.clearModel_(); | |
290 } | |
291 | |
292 // HistoryModel, Public: ------------------------------------------------------ | |
293 /** | |
294 * Sets our current view that is called when the history model changes. | |
295 * @param {HistoryView} view The view to set our current view to. | |
296 */ | |
297 HistoryModel.prototype.setView = function(view) { | |
298 this.view_ = view; | |
299 }; | |
300 | |
301 /** | |
302 * Start a new search - this will clear out our model. | |
303 * @param {String} searchText The text to search for | |
304 * @param {Number} opt_page The page to view - this is mostly used when setting | |
305 * up an initial view, use #requestPage otherwise. | |
306 */ | |
307 HistoryModel.prototype.setSearchText = function(searchText, opt_page) { | |
308 this.clearModel_(); | |
309 this.searchText_ = searchText; | |
310 this.requestedPage_ = opt_page ? opt_page : 0; | |
311 this.getSearchResults_(); | |
312 }; | |
313 | |
314 /** | |
315 * Reload our model with the current parameters. | |
316 */ | |
317 HistoryModel.prototype.reload = function() { | |
318 var search = this.searchText_; | |
319 var page = this.requestedPage_; | |
320 this.clearModel_(); | |
321 this.searchText_ = search; | |
322 this.requestedPage_ = page; | |
323 this.getSearchResults_(); | |
324 }; | |
325 | |
326 /** | |
327 * @return {String} The current search text. | |
328 */ | |
329 HistoryModel.prototype.getSearchText = function() { | |
330 return this.searchText_; | |
331 }; | |
332 | |
333 /** | |
334 * Tell the model that the view will want to see the current page. When | |
335 * the data becomes available, the model will call the view back. | |
336 * @page {Number} page The page we want to view. | |
337 */ | |
338 HistoryModel.prototype.requestPage = function(page) { | |
339 this.requestedPage_ = page; | |
340 this.changed = true; | |
341 this.updateSearch_(false); | |
342 }; | |
343 | |
344 /** | |
345 * Receiver for history query. | |
346 * @param {String} term The search term that the results are for. | |
347 * @param {Array} results A list of results | |
348 */ | |
349 HistoryModel.prototype.addResults = function(info, results) { | |
350 this.inFlight_ = false; | |
351 if (info.term != this.searchText_) { | |
352 // If our results aren't for our current search term, they're rubbish. | |
353 return; | |
354 } | |
355 | |
356 // Currently we assume we're getting things in date order. This needs to | |
357 // be updated if that ever changes. | |
358 if (results) { | |
359 var lastURL, lastDay; | |
360 var oldLength = this.pages_.length; | |
361 if (oldLength) { | |
362 var oldPage = this.pages_[oldLength - 1]; | |
363 lastURL = oldPage.url; | |
364 lastDay = oldPage.dateRelativeDay; | |
365 } | |
366 | |
367 for (var i = 0, thisResult; thisResult = results[i]; i++) { | |
368 var thisURL = thisResult.url; | |
369 var thisDay = thisResult.dateRelativeDay; | |
370 | |
371 // Remove adjacent duplicates. | |
372 if (!lastURL || lastURL != thisURL) { | |
373 // Figure out if this page is in the same day as the previous page, | |
374 // this is used to determine how day headers should be drawn. | |
375 this.pages_.push(new Page(thisResult, thisDay == lastDay, this, | |
376 this.last_id_++)); | |
377 lastDay = thisDay; | |
378 lastURL = thisURL; | |
379 } | |
380 } | |
381 if (results.length) | |
382 this.changed = true; | |
383 } | |
384 | |
385 this.updateSearch_(info.finished); | |
386 }; | |
387 | |
388 /** | |
389 * @return {Number} The number of pages in the model. | |
390 */ | |
391 HistoryModel.prototype.getSize = function() { | |
392 return this.pages_.length; | |
393 }; | |
394 | |
395 /** | |
396 * @return {boolean} Whether our history query has covered all of | |
397 * the user's history | |
398 */ | |
399 HistoryModel.prototype.isComplete = function() { | |
400 return this.complete_; | |
401 }; | |
402 | |
403 /** | |
404 * Get a list of pages between specified index positions. | |
405 * @param {Number} start The start index | |
406 * @param {Number} end The end index | |
407 * @return {Array} A list of pages | |
408 */ | |
409 HistoryModel.prototype.getNumberedRange = function(start, end) { | |
410 if (start >= this.getSize()) | |
411 return []; | |
412 | |
413 var end = end > this.getSize() ? this.getSize() : end; | |
414 return this.pages_.slice(start, end); | |
415 }; | |
416 | |
417 // HistoryModel, Private: ----------------------------------------------------- | |
418 HistoryModel.prototype.clearModel_ = function() { | |
419 this.inFlight_ = false; // Whether a query is inflight. | |
420 this.searchText_ = ''; | |
421 this.searchDepth_ = 0; | |
422 this.pages_ = []; // Date-sorted list of pages. | |
423 this.last_id_ = 0; | |
424 selectionAnchor = -1; | |
425 | |
426 // The page that the view wants to see - we only fetch slightly past this | |
427 // point. If the view requests a page that we don't have data for, we try | |
428 // to fetch it and call back when we're done. | |
429 this.requestedPage_ = 0; | |
430 | |
431 this.complete_ = false; | |
432 | |
433 if (this.view_) { | |
434 this.view_.clear_(); | |
435 } | |
436 }; | |
437 | |
438 /** | |
439 * Figure out if we need to do more searches to fill the currently requested | |
440 * page. If we think we can fill the page, call the view and let it know | |
441 * we're ready to show something. | |
442 */ | |
443 HistoryModel.prototype.updateSearch_ = function(finished) { | |
444 if ((this.searchText_ && this.searchDepth_ >= MAX_SEARCH_DEPTH_MONTHS) || | |
445 finished) { | |
446 // We have maxed out. There will be no more data. | |
447 this.complete_ = true; | |
448 this.view_.onModelReady(); | |
449 this.changed = false; | |
450 } else { | |
451 // If we can't fill the requested page, ask for more data unless a request | |
452 // is still in-flight. | |
453 if (!this.canFillPage_(this.requestedPage_) && !this.inFlight_) { | |
454 this.getSearchResults_(this.searchDepth_ + 1); | |
455 } | |
456 | |
457 // If we have any data for the requested page, show it. | |
458 if (this.changed && this.haveDataForPage_(this.requestedPage_)) { | |
459 this.view_.onModelReady(); | |
460 this.changed = false; | |
461 } | |
462 } | |
463 }; | |
464 | |
465 /** | |
466 * Get search results for a selected depth. Our history system is optimized | |
467 * for queries that don't cross month boundaries, but an entire month's | |
468 * worth of data is huge. When we're in browse mode (searchText is empty) | |
469 * we request the data a day at a time. When we're searching, a month is | |
470 * used. | |
471 * | |
472 * TODO: Fix this for when the user's clock goes across month boundaries. | |
473 * @param {number} opt_day How many days back to do the search. | |
474 */ | |
475 HistoryModel.prototype.getSearchResults_ = function(depth) { | |
476 this.searchDepth_ = depth || 0; | |
477 | |
478 if (this.searchText_ == "") { | |
479 chrome.send('getHistory', | |
480 [String(this.searchDepth_)]); | |
481 } else { | |
482 chrome.send('searchHistory', | |
483 [this.searchText_, String(this.searchDepth_)]); | |
484 } | |
485 | |
486 this.inFlight_ = true; | |
487 }; | |
488 | |
489 /** | |
490 * Check to see if we have data for a given page. | |
491 * @param {number} page The page number | |
492 * @return {boolean} Whether we have any data for the given page. | |
493 */ | |
494 HistoryModel.prototype.haveDataForPage_ = function(page) { | |
495 return (page * RESULTS_PER_PAGE < this.getSize()); | |
496 }; | |
497 | |
498 /** | |
499 * Check to see if we have data to fill a page. | |
500 * @param {number} page The page number. | |
501 * @return {boolean} Whether we have data to fill the page. | |
502 */ | |
503 HistoryModel.prototype.canFillPage_ = function(page) { | |
504 return ((page + 1) * RESULTS_PER_PAGE <= this.getSize()); | |
505 }; | |
506 | |
507 /////////////////////////////////////////////////////////////////////////////// | |
508 // HistoryView: | |
509 /** | |
510 * Functions and state for populating the page with HTML. This should one-day | |
511 * contain the view and use event handlers, rather than pushing HTML out and | |
512 * getting called externally. | |
513 * @param {HistoryModel} model The model backing this view. | |
514 */ | |
515 function HistoryView(model) { | |
516 this.summaryTd_ = $('results-summary'); | |
517 this.summaryTd_.textContent = localStrings.getString('loading'); | |
518 this.editButtonTd_ = $('edit-button'); | |
519 this.editingControlsDiv_ = $('editing-controls'); | |
520 this.resultDiv_ = $('results-display'); | |
521 this.pageDiv_ = $('results-pagination'); | |
522 this.model_ = model | |
523 this.pageIndex_ = 0; | |
524 this.lastDisplayed_ = []; | |
525 | |
526 this.model_.setView(this); | |
527 | |
528 this.currentPages_ = []; | |
529 | |
530 var self = this; | |
531 window.onresize = function() { | |
532 self.updateEntryAnchorWidth_(); | |
533 }; | |
534 | |
535 $('clear-browsing-data').addEventListener('click', openClearBrowsingData); | |
536 $('remove-selected').addEventListener('click', removeItems); | |
537 } | |
538 | |
539 // HistoryView, public: ------------------------------------------------------- | |
540 /** | |
541 * Do a search and optionally view a certain page. | |
542 * @param {string} term The string to search for. | |
543 * @param {number} opt_page The page we wish to view, only use this for | |
544 * setting up initial views, as this triggers a search. | |
545 */ | |
546 HistoryView.prototype.setSearch = function(term, opt_page) { | |
547 this.pageIndex_ = parseInt(opt_page || 0, 10); | |
548 window.scrollTo(0, 0); | |
549 this.model_.setSearchText(term, this.pageIndex_); | |
550 pageState.setUIState(term, this.pageIndex_); | |
551 }; | |
552 | |
553 /** | |
554 * Reload the current view. | |
555 */ | |
556 HistoryView.prototype.reload = function() { | |
557 this.model_.reload(); | |
558 this.updateRemoveButton(); | |
559 }; | |
560 | |
561 /** | |
562 * Switch to a specified page. | |
563 * @param {number} page The page we wish to view. | |
564 */ | |
565 HistoryView.prototype.setPage = function(page) { | |
566 this.clear_(); | |
567 this.pageIndex_ = parseInt(page, 10); | |
568 window.scrollTo(0, 0); | |
569 this.model_.requestPage(page); | |
570 pageState.setUIState(this.model_.getSearchText(), this.pageIndex_); | |
571 }; | |
572 | |
573 /** | |
574 * @return {number} The page number being viewed. | |
575 */ | |
576 HistoryView.prototype.getPage = function() { | |
577 return this.pageIndex_; | |
578 }; | |
579 | |
580 /** | |
581 * Callback for the history model to let it know that it has data ready for us | |
582 * to view. | |
583 */ | |
584 HistoryView.prototype.onModelReady = function() { | |
585 this.displayResults_(); | |
586 }; | |
587 | |
588 /** | |
589 * Enables or disables the 'Remove selected items' button as appropriate. | |
590 */ | |
591 HistoryView.prototype.updateRemoveButton = function() { | |
592 var anyChecked = document.querySelector('.entry input:checked') != null; | |
593 $('remove-selected').disabled = !anyChecked; | |
594 } | |
595 | |
596 // HistoryView, private: ------------------------------------------------------ | |
597 /** | |
598 * Clear the results in the view. Since we add results piecemeal, we need | |
599 * to clear them out when we switch to a new page or reload. | |
600 */ | |
601 HistoryView.prototype.clear_ = function() { | |
602 this.resultDiv_.textContent = ''; | |
603 | |
604 var pages = this.currentPages_; | |
605 for (var i = 0; i < pages.length; i++) { | |
606 pages[i].isRendered = false; | |
607 } | |
608 this.currentPages_ = []; | |
609 }; | |
610 | |
611 HistoryView.prototype.setPageRendered_ = function(page) { | |
612 page.isRendered = true; | |
613 this.currentPages_.push(page); | |
614 }; | |
615 | |
616 /** | |
617 * Update the page with results. | |
618 */ | |
619 HistoryView.prototype.displayResults_ = function() { | |
620 var results = this.model_.getNumberedRange( | |
621 this.pageIndex_ * RESULTS_PER_PAGE, | |
622 this.pageIndex_ * RESULTS_PER_PAGE + RESULTS_PER_PAGE); | |
623 | |
624 if (this.model_.getSearchText()) { | |
625 var searchResults = createElementWithClassName('ol', 'search-results'); | |
626 for (var i = 0, page; page = results[i]; i++) { | |
627 if (!page.isRendered) { | |
628 searchResults.appendChild(page.getResultDOM(true)); | |
629 this.setPageRendered_(page); | |
630 } | |
631 } | |
632 this.resultDiv_.appendChild(searchResults); | |
633 } else { | |
634 var resultsFragment = document.createDocumentFragment(); | |
635 var lastTime = Math.infinity; | |
636 var dayResults; | |
637 for (var i = 0, page; page = results[i]; i++) { | |
638 if (page.isRendered) { | |
639 continue; | |
640 } | |
641 // Break across day boundaries and insert gaps for browsing pauses. | |
642 // Create a dayResults element to contain results for each day | |
643 var thisTime = page.time.getTime(); | |
644 | |
645 if ((i == 0 && page.continued) || !page.continued) { | |
646 var day = createElementWithClassName('h2', 'day'); | |
647 day.appendChild(document.createTextNode(page.dateRelativeDay)); | |
648 if (i == 0 && page.continued) { | |
649 day.appendChild(document.createTextNode(' ' + | |
650 localStrings.getString('cont'))); | |
651 } | |
652 | |
653 // If there is an existing dayResults element, append it. | |
654 if (dayResults) { | |
655 resultsFragment.appendChild(dayResults); | |
656 } | |
657 resultsFragment.appendChild(day); | |
658 dayResults = createElementWithClassName('ol', 'day-results'); | |
659 } else if (lastTime - thisTime > BROWSING_GAP_TIME) { | |
660 if (dayResults) { | |
661 dayResults.appendChild(createElementWithClassName('li', 'gap')); | |
662 } | |
663 } | |
664 lastTime = thisTime; | |
665 // Add entry. | |
666 if (dayResults) { | |
667 dayResults.appendChild(page.getResultDOM(false)); | |
668 this.setPageRendered_(page); | |
669 } | |
670 } | |
671 // Add final dayResults element. | |
672 if (dayResults) { | |
673 resultsFragment.appendChild(dayResults); | |
674 } | |
675 this.resultDiv_.appendChild(resultsFragment); | |
676 } | |
677 | |
678 this.displaySummaryBar_(); | |
679 this.displayNavBar_(); | |
680 this.updateEntryAnchorWidth_(); | |
681 }; | |
682 | |
683 /** | |
684 * Update the summary bar with descriptive text. | |
685 */ | |
686 HistoryView.prototype.displaySummaryBar_ = function() { | |
687 var searchText = this.model_.getSearchText(); | |
688 if (searchText != '') { | |
689 this.summaryTd_.textContent = localStrings.getStringF('searchresultsfor', | |
690 searchText); | |
691 } else { | |
692 this.summaryTd_.textContent = localStrings.getString('history'); | |
693 } | |
694 }; | |
695 | |
696 /** | |
697 * Update the pagination tools. | |
698 */ | |
699 HistoryView.prototype.displayNavBar_ = function() { | |
700 this.pageDiv_.textContent = ''; | |
701 | |
702 if (this.pageIndex_ > 0) { | |
703 this.pageDiv_.appendChild( | |
704 this.createPageNav_(0, localStrings.getString('newest'))); | |
705 this.pageDiv_.appendChild( | |
706 this.createPageNav_(this.pageIndex_ - 1, | |
707 localStrings.getString('newer'))); | |
708 } | |
709 | |
710 // TODO(feldstein): this causes the navbar to not show up when your first | |
711 // page has the exact amount of results as RESULTS_PER_PAGE. | |
712 if (this.model_.getSize() > (this.pageIndex_ + 1) * RESULTS_PER_PAGE) { | |
713 this.pageDiv_.appendChild( | |
714 this.createPageNav_(this.pageIndex_ + 1, | |
715 localStrings.getString('older'))); | |
716 } | |
717 }; | |
718 | |
719 /** | |
720 * Make a DOM object representation of a page navigation link. | |
721 * @param {number} page The page index the navigation element should link to | |
722 * @param {string} name The text content of the link | |
723 * @return {HTMLAnchorElement} the pagination link | |
724 */ | |
725 HistoryView.prototype.createPageNav_ = function(page, name) { | |
726 anchor = document.createElement('a'); | |
727 anchor.className = 'page-navigation'; | |
728 anchor.textContent = name; | |
729 var hashString = PageState.getHashString(this.model_.getSearchText(), page); | |
730 var link = 'chrome://history2/' + (hashString ? '#' + hashString : ''); | |
731 anchor.href = link; | |
732 anchor.onclick = function() { | |
733 setPage(page); | |
734 return false; | |
735 }; | |
736 return anchor; | |
737 }; | |
738 | |
739 /** | |
740 * Updates the CSS rule for the entry anchor. | |
741 * @private | |
742 */ | |
743 HistoryView.prototype.updateEntryAnchorWidth_ = function() { | |
744 // We need to have at least on .title div to be able to calculate the | |
745 // desired width of the anchor. | |
746 var titleElement = document.querySelector('.entry .title'); | |
747 if (!titleElement) | |
748 return; | |
749 | |
750 // Create new CSS rules and add them last to the last stylesheet. | |
751 // TODO(jochen): The following code does not work due to WebKit bug #32309 | |
752 // if (!this.entryAnchorRule_) { | |
753 // var styleSheets = document.styleSheets; | |
754 // var styleSheet = styleSheets[styleSheets.length - 1]; | |
755 // var rules = styleSheet.cssRules; | |
756 // var createRule = function(selector) { | |
757 // styleSheet.insertRule(selector + '{}', rules.length); | |
758 // return rules[rules.length - 1]; | |
759 // }; | |
760 // this.entryAnchorRule_ = createRule('.entry .title > a'); | |
761 // // The following rule needs to be more specific to have higher priority. | |
762 // this.entryAnchorStarredRule_ = createRule('.entry .title.starred > a'); | |
763 // } | |
764 // | |
765 // var anchorMaxWith = titleElement.offsetWidth; | |
766 // this.entryAnchorRule_.style.maxWidth = anchorMaxWith + 'px'; | |
767 // // Adjust by the width of star plus its margin. | |
768 // this.entryAnchorStarredRule_.style.maxWidth = anchorMaxWith - 23 + 'px'; | |
769 }; | |
770 | |
771 /////////////////////////////////////////////////////////////////////////////// | |
772 // State object: | |
773 /** | |
774 * An 'AJAX-history' implementation. | |
775 * @param {HistoryModel} model The model we're representing | |
776 * @param {HistoryView} view The view we're representing | |
777 */ | |
778 function PageState(model, view) { | |
779 // Enforce a singleton. | |
780 if (PageState.instance) { | |
781 return PageState.instance; | |
782 } | |
783 | |
784 this.model = model; | |
785 this.view = view; | |
786 | |
787 if (typeof this.checker_ != 'undefined' && this.checker_) { | |
788 clearInterval(this.checker_); | |
789 } | |
790 | |
791 // TODO(glen): Replace this with a bound method so we don't need | |
792 // public model and view. | |
793 this.checker_ = setInterval((function(state_obj) { | |
794 var hashData = state_obj.getHashData(); | |
795 | |
796 if (hashData.q != state_obj.model.getSearchText(term)) { | |
797 state_obj.view.setSearch(hashData.q, parseInt(hashData.p, 10)); | |
798 } else if (parseInt(hashData.p, 10) != state_obj.view.getPage()) { | |
799 state_obj.view.setPage(hashData.p); | |
800 } | |
801 }), 50, this); | |
802 } | |
803 | |
804 PageState.instance = null; | |
805 | |
806 /** | |
807 * @return {Object} An object containing parameters from our window hash. | |
808 */ | |
809 PageState.prototype.getHashData = function() { | |
810 var result = { | |
811 e : 0, | |
812 q : '', | |
813 p : 0 | |
814 }; | |
815 | |
816 if (!window.location.hash) { | |
817 return result; | |
818 } | |
819 | |
820 var hashSplit = window.location.hash.substr(1).split('&'); | |
821 for (var i = 0; i < hashSplit.length; i++) { | |
822 var pair = hashSplit[i].split('='); | |
823 if (pair.length > 1) { | |
824 result[pair[0]] = decodeURIComponent(pair[1].replace(/\+/g, ' ')); | |
825 } | |
826 } | |
827 | |
828 return result; | |
829 }; | |
830 | |
831 /** | |
832 * Set the hash to a specified state, this will create an entry in the | |
833 * session history so the back button cycles through hash states, which | |
834 * are then picked up by our listener. | |
835 * @param {string} term The current search string. | |
836 * @param {string} page The page currently being viewed. | |
837 */ | |
838 PageState.prototype.setUIState = function(term, page) { | |
839 // Make sure the form looks pretty. | |
840 document.forms[0].term.value = term; | |
841 var currentHash = this.getHashData(); | |
842 if (currentHash.q != term || currentHash.p != page) { | |
843 window.location.hash = PageState.getHashString(term, page); | |
844 } | |
845 }; | |
846 | |
847 /** | |
848 * Static method to get the hash string for a specified state | |
849 * @param {string} term The current search string. | |
850 * @param {string} page The page currently being viewed. | |
851 * @return {string} The string to be used in a hash. | |
852 */ | |
853 PageState.getHashString = function(term, page) { | |
854 var newHash = []; | |
855 if (term) { | |
856 newHash.push('q=' + encodeURIComponent(term)); | |
857 } | |
858 if (page != undefined) { | |
859 newHash.push('p=' + page); | |
860 } | |
861 | |
862 return newHash.join('&'); | |
863 }; | |
864 | |
865 /////////////////////////////////////////////////////////////////////////////// | |
866 // Document Functions: | |
867 /** | |
868 * Window onload handler, sets up the page. | |
869 */ | |
870 function load() { | |
871 $('term').focus(); | |
872 | |
873 localStrings = new LocalStrings(); | |
874 historyModel = new HistoryModel(); | |
875 historyView = new HistoryView(historyModel); | |
876 pageState = new PageState(historyModel, historyView); | |
877 | |
878 // Create default view. | |
879 var hashData = pageState.getHashData(); | |
880 historyView.setSearch(hashData.q, hashData.p); | |
881 | |
882 // Setup click handlers. | |
883 $('history-section').onclick = function () { | |
884 setSearch(''); | |
885 return false; | |
886 }; | |
887 $('search-form').onsubmit = function () { | |
888 setSearch(this.term.value); | |
889 return false; | |
890 }; | |
891 | |
892 $('remove-page').addEventListener('activate', function(e) { | |
893 activePage.removeFromHistory_(); | |
894 activePage = null; | |
895 }); | |
896 $('more-from-site').addEventListener('activate', function(e) { | |
897 activePage.showMoreFromSite_(); | |
898 activePage = null; | |
899 }); | |
900 } | |
901 | |
902 /** | |
903 * TODO(glen): Get rid of this function. | |
904 * Set the history view to a specified page. | |
905 * @param {String} term The string to search for | |
906 */ | |
907 function setSearch(term) { | |
908 if (historyView) { | |
909 historyView.setSearch(term); | |
910 } | |
911 } | |
912 | |
913 /** | |
914 * TODO(glen): Get rid of this function. | |
915 * Set the history view to a specified page. | |
916 * @param {number} page The page to set the view to. | |
917 */ | |
918 function setPage(page) { | |
919 if (historyView) { | |
920 historyView.setPage(page); | |
921 } | |
922 } | |
923 | |
924 /** | |
925 * Delete the next item in our deletion queue. | |
926 */ | |
927 function deleteNextInQueue() { | |
928 if (deleteQueue.length > 0) { | |
929 // Call the native function to remove history entries. | |
930 // First arg is a time in seconds (passed as String) identifying the day. | |
931 // Remaining args are URLs of history entries from that day to delete. | |
932 var timeInSeconds = Math.floor(deleteQueue[0].date.getTime() / 1000); | |
933 chrome.send('removeURLsOnOneDay', | |
934 [String(timeInSeconds)].concat(deleteQueue[0].urls)); | |
935 } | |
936 } | |
937 | |
938 /** | |
939 * Open the clear browsing data dialog. | |
940 */ | |
941 function openClearBrowsingData() { | |
942 chrome.send('clearBrowsingData', []); | |
943 return false; | |
944 } | |
945 | |
946 /** | |
947 * Queue a set of URLs from the same day for deletion. | |
948 * @param {Date} date A date indicating the day the URLs were visited. | |
949 * @param {Array} urls Array of URLs from the same day to be deleted. | |
950 * @param {Function} opt_callback An optional callback to be executed when | |
951 * the deletion is complete. | |
952 */ | |
953 function queueURLsForDeletion(date, urls, opt_callback) { | |
954 deleteQueue.push({ 'date': date, 'urls': urls, 'callback': opt_callback }); | |
955 } | |
956 | |
957 function reloadHistory() { | |
958 historyView.reload(); | |
959 } | |
960 | |
961 /** | |
962 * Collect IDs from checked checkboxes and send to Chrome for deletion. | |
963 */ | |
964 function removeItems() { | |
965 var checked = document.querySelectorAll( | |
966 'input[type=checkbox]:checked:not([disabled])'); | |
967 var urls = []; | |
968 var disabledItems = []; | |
969 var queue = []; | |
970 var date = new Date(); | |
971 | |
972 for (var i = 0; i < checked.length; i++) { | |
973 var checkbox = checked[i]; | |
974 var cbDate = new Date(checkbox.time); | |
975 if (date.getFullYear() != cbDate.getFullYear() || | |
976 date.getMonth() != cbDate.getMonth() || | |
977 date.getDate() != cbDate.getDate()) { | |
978 if (urls.length > 0) { | |
979 queue.push([date, urls]); | |
980 } | |
981 urls = []; | |
982 date = cbDate; | |
983 } | |
984 var link = checkbox.parentNode.parentNode.querySelector('a'); | |
985 checkbox.disabled = true; | |
986 link.classList.add('to-be-removed'); | |
987 disabledItems.push(checkbox); | |
988 urls.push(link.href); | |
989 } | |
990 if (urls.length > 0) { | |
991 queue.push([date, urls]); | |
992 } | |
993 if (checked.length > 0 && confirm(localStrings.getString('deletewarning'))) { | |
994 for (var i = 0; i < queue.length; i++) { | |
995 // Reload the page when the final entry has been deleted. | |
996 var callback = i == 0 ? reloadHistory : null; | |
997 | |
998 queueURLsForDeletion(queue[i][0], queue[i][1], callback); | |
999 } | |
1000 deleteNextInQueue(); | |
1001 } else { | |
1002 // If the remove is cancelled, return the checkboxes to their | |
1003 // enabled, non-line-through state. | |
1004 for (var i = 0; i < disabledItems.length; i++) { | |
1005 var checkbox = disabledItems[i]; | |
1006 var link = checkbox.parentNode.parentNode.querySelector('a'); | |
1007 checkbox.disabled = false; | |
1008 link.classList.remove('to-be-removed'); | |
1009 } | |
1010 } | |
1011 return false; | |
1012 } | |
1013 | |
1014 /** | |
1015 * Toggle state of checkbox and handle Shift modifier. | |
1016 */ | |
1017 function checkboxClicked(event) { | |
1018 var id = Number(this.id.slice("checkbox-".length)); | |
1019 if (event.shiftKey && (selectionAnchor != -1)) { | |
1020 var checked = this.checked; | |
1021 // Set all checkboxes from the anchor up to the clicked checkbox to the | |
1022 // state of the clicked one. | |
1023 var begin = Math.min(id, selectionAnchor); | |
1024 var end = Math.max(id, selectionAnchor); | |
1025 for (var i = begin; i <= end; i++) { | |
1026 var checkbox = document.querySelector('#checkbox-' + i); | |
1027 if (checkbox) | |
1028 checkbox.checked = checked; | |
1029 } | |
1030 } | |
1031 selectionAnchor = id; | |
1032 | |
1033 historyView.updateRemoveButton(); | |
1034 } | |
1035 | |
1036 function entryBoxMousedown(event) { | |
1037 // Prevent text selection when shift-clicking to select multiple entries. | |
1038 if (event.shiftKey) { | |
1039 event.preventDefault(); | |
1040 } | |
1041 } | |
1042 | |
1043 function removeNode(node) { | |
1044 node.classList.add('fade-out'); // Trigger CSS fade out animation. | |
1045 | |
1046 // Delete the node when the animation is complete. | |
1047 node.addEventListener('webkitTransitionEnd', function() { | |
1048 node.parentNode.removeChild(node); | |
1049 }); | |
1050 } | |
1051 | |
1052 /** | |
1053 * Removes a single entry from the view. Also removes gaps before and after | |
1054 * entry if necessary. | |
1055 */ | |
1056 function removeEntryFromView(entry) { | |
1057 var nextEntry = entry.nextSibling; | |
1058 var previousEntry = entry.previousSibling; | |
1059 | |
1060 removeNode(entry); | |
1061 | |
1062 // if there is no previous entry, and the next entry is a gap, remove it | |
1063 if (!previousEntry && nextEntry && nextEntry.className == 'gap') { | |
1064 removeNode(nextEntry); | |
1065 } | |
1066 | |
1067 // if there is no next entry, and the previous entry is a gap, remove it | |
1068 if (!nextEntry && previousEntry && previousEntry.className == 'gap') { | |
1069 removeNode(previousEntry); | |
1070 } | |
1071 | |
1072 // if both the next and previous entries are gaps, remove one | |
1073 if (nextEntry && nextEntry.className == 'gap' && | |
1074 previousEntry && previousEntry.className == 'gap') { | |
1075 removeNode(nextEntry); | |
1076 } | |
1077 } | |
1078 | |
1079 /////////////////////////////////////////////////////////////////////////////// | |
1080 // Chrome callbacks: | |
1081 /** | |
1082 * Our history system calls this function with results from searches. | |
1083 */ | |
1084 function historyResult(info, results) { | |
1085 historyModel.addResults(info, results); | |
1086 } | |
1087 | |
1088 /** | |
1089 * Our history system calls this function when a deletion has finished. | |
1090 */ | |
1091 function deleteComplete() { | |
1092 if (deleteQueue.length > 0) { | |
1093 // Remove the successfully deleted entry from the queue. | |
1094 if (deleteQueue[0].callback) | |
1095 deleteQueue[0].callback.apply(); | |
1096 deleteQueue.splice(0, 1); | |
1097 deleteNextInQueue(); | |
1098 } else { | |
1099 console.error('Received deleteComplete but queue is empty.'); | |
1100 } | |
1101 } | |
1102 | |
1103 /** | |
1104 * Our history system calls this function if a delete is not ready (e.g. | |
1105 * another delete is in-progress). | |
1106 */ | |
1107 function deleteFailed() { | |
1108 window.console.log('Delete failed'); | |
1109 | |
1110 // The deletion failed - try again later. | |
1111 // TODO(dubroy): We should probably give up at some point. | |
1112 setTimeout(deleteNextInQueue, 500); | |
1113 } | |
1114 | |
1115 /** | |
1116 * Called when the history is deleted by someone else. | |
1117 */ | |
1118 function historyDeleted() { | |
1119 window.console.log('History deleted'); | |
1120 var anyChecked = document.querySelector('.entry input:checked') != null; | |
1121 // Reload the page, unless the user has any items checked. | |
1122 // TODO(dubroy): We should just reload the page & restore the checked items. | |
1123 if (!anyChecked) | |
1124 historyView.reload(); | |
1125 } | |
1126 | |
1127 // Add handlers to HTML elements. | |
1128 document.addEventListener('DOMContentLoaded', load); | |
1129 | |
1130 // This event lets us enable and disable menu items before the menu is shown. | |
1131 document.addEventListener('canExecute', function(e) { | |
1132 e.canExecute = true; | |
1133 }); | |
OLD | NEW |