diff options
author | droger <droger@chromium.org> | 2016-01-14 08:55:36 -0800 |
---|---|---|
committer | Commit bot <commit-bot@chromium.org> | 2016-01-14 16:57:14 +0000 |
commit | fad78b3b680b864536515744b1b2ba0ca4a642ae (patch) | |
tree | 4bbf76b1b7c344c853954704ffee6ca9af393039 /ios | |
parent | e4e16091487cf5a42167c3b48d3dc7fda85cb9a0 (diff) | |
download | chromium_src-fad78b3b680b864536515744b1b2ba0ca4a642ae.zip chromium_src-fad78b3b680b864536515744b1b2ba0ca4a642ae.tar.gz chromium_src-fad78b3b680b864536515744b1b2ba0ca4a642ae.tar.bz2 |
[iOS] Duplicate HTML resources for chrome://history
References to unused //chrome resources are removed on iOS.
This duplication is temporary until a native UI is implemented on iOS,
and is done to unblock the upstreaming of iOS code.
NOPRESUBMIT=true
BUG=554966
Review URL: https://codereview.chromium.org/1583493002
Cr-Commit-Position: refs/heads/master@{#369434}
Diffstat (limited to 'ios')
-rw-r--r-- | ios/chrome/app/resources/history/OWNERS | 1 | ||||
-rw-r--r-- | ios/chrome/app/resources/history/alert_overlay.css | 20 | ||||
-rw-r--r-- | ios/chrome/app/resources/history/alert_overlay.html | 13 | ||||
-rw-r--r-- | ios/chrome/app/resources/history/history.css | 631 | ||||
-rw-r--r-- | ios/chrome/app/resources/history/history.html | 115 | ||||
-rw-r--r-- | ios/chrome/app/resources/history/history.js | 2328 | ||||
-rw-r--r-- | ios/chrome/app/resources/history/history_mobile.css | 330 | ||||
-rw-r--r-- | ios/chrome/app/resources/history/other_devices.js | 573 | ||||
-rw-r--r-- | ios/chrome/app/resources/ios_resources.grd | 3 |
9 files changed, 4014 insertions, 0 deletions
diff --git a/ios/chrome/app/resources/history/OWNERS b/ios/chrome/app/resources/history/OWNERS new file mode 100644 index 0000000..936c033 --- /dev/null +++ b/ios/chrome/app/resources/history/OWNERS @@ -0,0 +1 @@ +jyquinn@chromium.org diff --git a/ios/chrome/app/resources/history/alert_overlay.css b/ios/chrome/app/resources/history/alert_overlay.css new file mode 100644 index 0000000..5c6409a --- /dev/null +++ b/ios/chrome/app/resources/history/alert_overlay.css @@ -0,0 +1,20 @@ +/* Copyright 2013 The Chromium Authors. All rights reserved. + * Use of this source code is governed by a BSD-style license that can be + * found in the LICENSE file. */ + +.overlay .page .button-strip > button { + min-width: 7em; +} + +.overlay { + z-index: 4; +} + +#alertOverlayMessage { + width: 400px; +} + +#alertOverlayMessage { + white-space: pre-wrap; + word-wrap: break-word; +}
\ No newline at end of file diff --git a/ios/chrome/app/resources/history/alert_overlay.html b/ios/chrome/app/resources/history/alert_overlay.html new file mode 100644 index 0000000..34494d6 --- /dev/null +++ b/ios/chrome/app/resources/history/alert_overlay.html @@ -0,0 +1,13 @@ +<div id="alertOverlay" class="page"> + <div class="close-button"></div> + <h1 id="alertOverlayTitle"></h1> + <div class="content-area"> + <div id="alertOverlayMessage"></div> + </div> + <div class="action-area"> + <div class="button-strip"> + <button id="alertOverlayCancel" type="reset"></button> + <button id="alertOverlayOk" class="default-button" type="submit"></button> + </div> + </div> +</div> diff --git a/ios/chrome/app/resources/history/history.css b/ios/chrome/app/resources/history/history.css new file mode 100644 index 0000000..dd04428 --- /dev/null +++ b/ios/chrome/app/resources/history/history.css @@ -0,0 +1,631 @@ +/* Copyright (c) 2012 The Chromium Authors. All rights reserved. + * Use of this source code is governed by a BSD-style license that can be + * found in the LICENSE file. */ + +html[dir='rtl'] body.uber-frame > .page { + -webkit-margin-end: 0; +} + +body.uber-frame > .page.big-topbar-page { + padding-top: 78px; +} + +#top-container { + margin-top: 16px; + overflow: hidden; +} + +#editing-controls, +#loading-spinner { + white-space: nowrap; +} + +#search-button { + margin: 0; +} + +#spinner { + -webkit-margin-end: 5px; + vertical-align: bottom; +} + +#notification-bar { + float: right; + padding-top: 5px; +} + +html[dir='rtl'] #notification-bar { + float: left; +} + +#notification-bar.alone { + float: left; + margin-top: 12px; +} + +html[dir='rtl'] #notification-bar.alone { + float: right; +} + +#filter-controls, +#top-container, +#results-display, +#results-pagination { + max-width: 718px; +} + +#filter-controls { + display: flex; + margin-bottom: 4px; + margin-top: 4px; +} + +#filter-controls > * { + flex: 1; +} + +#editing-controls { + -webkit-margin-end: 12px; + float: left; +} + +html[dir='rtl'] #editing-controls { + float: right; +} + +#editing-controls button:first-of-type { + -webkit-margin-start: 0; +} + +#range-next, +#range-previous { + background-position: center; + background-repeat: no-repeat; + border-radius: 0 2px 2px 0; +} + +#range-next:disabled, +#range-previous:disabled { + border-color: rgba(67, 67, 67, 0.5); + opacity: 0.5; +} + +html[dir='rtl'] #range-today, +html[dir='rtl'] #range-previous, +html[dir='rtl'] #range-next { + float: right; +} + +html[dir='rtl'] #range-next, +#range-previous { + -webkit-transform: scalex(-1); +} + +html[dir='rtl'] #range-previous { + -webkit-transform: scaleX(1); +} + +#range-today { + -webkit-margin-end: 10px; +} + +#range-today, +#range-previous, +#range-next { + float: left; + height: 26px; + padding-bottom: 4px; + padding-top: 4px; +} + +#range-next { + -webkit-margin-start: -1px; +} + +#range-previous { + -webkit-margin-end: 0; +} + +#timeframe-controls { + display: flex; + justify-content: flex-end; +} + +#timeframe-controls input[type='radio'] { + background-color: buttonface; + background-image: -webkit-linear-gradient(rgb(237, 237, 237), + rgb(237, 237, 237) 38%, + rgb(222, 222, 222)); + border: 1px solid rgba(0, 0, 0, 0.25); + border-radius: 0; + bottom: auto; + box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08), + inset 0 1px 2px rgba(255, 255, 255, 0.75); + box-sizing: border-box; + color: rgb(68, 68, 68); + display: inline-block; + height: 100%; + letter-spacing: normal; + line-height: 2em; + margin: 0; + min-height: 2em; + min-width: 4em; + text-align: center; + text-indent: 0; + text-shadow: 0 1px 0 rgb(240, 240, 240); + text-transform: none; + vertical-align: middle; + width: auto; + word-spacing: normal; +} + +#timeframe-controls input[type='radio']:not(:first-of-type) { + -webkit-border-start-width: 0; +} + +#timeframe-controls input[type='radio']:not(:first-of-type):focus { + -webkit-border-start-width: 1px; + -webkit-margin-start: -1px; +} + +html[dir='ltr'] #timeframe-controls input[type='radio']:first-of-type, +html[dir='rtl'] #timeframe-controls input[type='radio']:last-of-type { + border-bottom-left-radius: 2px; + border-top-left-radius: 2px; +} + +html[dir='ltr'] #timeframe-controls input[type='radio']:last-of-type, +html[dir='rtl'] #timeframe-controls input[type='radio']:first-of-type { + border-bottom-right-radius: 2px; + border-top-right-radius: 2px; +} + +#timeframe-controls input[type='radio']:checked { + background-image: -webkit-linear-gradient(rgb(185, 185, 185), + rgb(216, 216, 216) 38%, + rgb(167, 167, 167)); +} + +#timeframe-controls input[type='radio']:focus { + border-color: rgb(77, 144, 254); +} + +#timeframe-controls input[type='radio']::before { + display: none; /* Hide the ( )/(O). */ +} + +#timeframe-controls input[type='radio']::after { + content: attr(aria-label); + padding: 0 1em; +} + +#results-display { + margin: 16px 0 0 0; +} + +.edit-button { + -webkit-appearance: none; + background: none; + border: 0; + color: blue; /* -webkit-link makes it purple :'( */ + cursor: pointer; + display: inline-block; + font: inherit; + padding: 0 9px; + text-decoration: underline; +} + +.entry, +.gap, +.no-entries, +.site-entry { + list-style: none; + margin: 0; + padding: 0; +} + +.gap { + -webkit-border-end: 1px solid rgb(192, 195, 198); + height: 14px; + margin: 1px 0; + width: 45px; +} + +.no-checkboxes .gap { + width: 25px; +} + +.entry-box, +.site-domain-row { + align-items: center; + display: flex; + margin-bottom: 6px; + /* The box should be no bigger than its parent. */ + max-width: 100%; + min-height: 2em; + overflow: hidden; + padding-bottom: 1px; +} + +.entry-box { + /* Ensures a consistent baseline on all platforms. */ + line-height: 1.75em; +} + +.site-domain-wrapper { + cursor: pointer; + display: flex; + width: 100%; +} + +.search-results, +.day-results { + margin: 0 0 24px 0; + padding: 0; +} + +.site-results { + -webkit-transition: height 350ms ease-in-out; + clear: left; + margin: 0; + overflow: hidden; + padding: 0; +} + +.site-results.grouped { + -webkit-padding-start: 18px; +} + +.no-checkboxes .site-results.grouped { + -webkit-padding-start: 21px; +} + +.month-results { + -webkit-padding-start: 0; +} + +html[dir='rtl'] .site-results { + clear: both; +} + +h2.timeframe { + font-size: 1.5em; +} + +.entry .domain { + -webkit-padding-end: 6px; + -webkit-padding-start: 2px; + color: rgb(151, 156, 160); + min-width: -webkit-min-content; + overflow: hidden; + white-space: nowrap; +} + +.site-results .domain { + display: none; +} + +html[dir='rtl'] .number-visits { + /* This element contains parentheses, which without the unicode-bidi: embed + * directive would show up incorrectly (e.g. '(www.google.com (5'). Using + * 'embed' makes the engine set the text in the parentheses as LTR even + * when the layout is set to RTL, which makes using -webkit-*-start + * impossible. So use margins and dir='rtl'. + */ + direction: rtl; + unicode-bidi: embed; +} + +.number-visits { + color: rgb(151, 156, 160); +} + +.drop-down { + margin-top: 1px; +} + +html[dir='rtl'] .entry .title, +html[dir='rtl'] .site-domain { + /* Put the favicon on the right. */ + background-position-x: right; +} + +.entry .time { + color: rgb(151, 156, 160); + max-width: 90px; + min-width: -webkit-min-content; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.entry input[type='checkbox'], +.site-domain-row input[type='checkbox'] { + -webkit-margin-end: 6px; + line-height: 1em; + min-width: 13px; + top: 0; +} + +.site-domain-wrapper:hover input[type='checkbox']:not(:focus), +.site-domain-wrapper input[type='checkbox']:not(:focus):checked, +.entry-box:hover input[type='checkbox']:not(:focus), +.entry-box input[type='checkbox']:not(:focus):checked { + border-color: rgba(0, 0, 0, .5); +} + +.site-domain-wrapper:hover .site-domain-row, +.entry-box:hover { + background-color: rgba(0, 0, 0, .025); +} + +.filter-status { + -webkit-margin-start: 10px; +} + +.filter-status > div { + flex: 0 0 auto; + -webkit-transition: background-color 150ms; + border-radius: 3px; + display: none; + font-size: 11px; + height: 14px; + line-height: 12px; + white-space: nowrap; +} + +.filter-status > div.filter-allowed, +.filter-status > div.filter-blocked, +.filter-status > div.in-content-pack-active, +.filter-status > div.in-content-pack-passive, +.filter-status > div.blocked-visit-active { + display: block; + margin: 3px 3px 3px 0; + padding: 0 4px; +} + +.filter-allowed, +.in-content-pack-active { + background-color: rgb(141, 240, 127); + border: 1px solid rgb(33, 190, 33); + color: rgb(54, 54, 54); +} + +.filter-blocked { + border: 1px solid rgb(207, 207, 207); + background-color: rgb(231, 231, 231); + color: rgb(54, 54, 54); +} + +.in-content-pack-passive { + border: 1px solid rgb(155, 224, 163); + background-color: rgb(225, 255, 205); + color: rgb(148, 148, 148); +} + +.entry-box, +.site-domain-row { + -webkit-padding-end: 6px; + -webkit-padding-start: 6px; + border-radius: 2px; +} + +.entry-box > div, +.site-domain-row > div { + min-width: 0; +} + +.focus-row-active :-webkit-any(.entry-box, .site-domain-row) { + background-color: rgba(0, 0, 0, .05); +} + +.entry-box-container { + display: flex; +} + +.entry .visit-entry { + display: flex; + min-width: 0; +} + +.entry .title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.entry .visit-entry, +.site-domain { + /* Make room for the favicon. */ + -webkit-padding-start: 16px; +} + +.entry .visit-entry, +.site-domain, +.blocked-indicator { + /* Control the favicon appearance. */ + background-position-y: center; + background-repeat: no-repeat; + background-size: 16px; +} + +html[dir='rtl'] .entry .visit-entry, +html[dir='rtl'] .site-domain, +html[dir='rtl'] .blocked-indicator { + background-position-x: right; +} + +/* TODO(sergiu): If this is the final icon replace it with a separate resource. + */ +.entry .blocked-indicator { + -webkit-padding-start: 20px; /* 16px for favicon, 4px for <a> padding. */ +} + +.blocked-indicator .title { + color: rgb(151, 156, 160); +} + +.site-domain button:hover { + text-decoration: none; +} + +.site-domain-arrow { + -webkit-transform: rotate(0); + -webkit-transition: -webkit-transform 300ms linear; + background-position: 5px 5px; + color: rgb(143, 143, 143); + height: 21px; + margin-right: 2px; + opacity: 0.58; + text-align: center; + width: 21px; +} + +html[dir='rtl'] .site-domain-arrow { + -webkit-transform: rotate(180deg); +} + +html .expand .site-domain-arrow { + -webkit-transform: rotate(90deg); +} + +.entry .bookmark-section { + -webkit-margin-end: 3px; + -webkit-margin-start: 8px; + background: no-repeat center + url(../../../../../ui/webui/resources/images/star_small.png); + border: none; + display: inline-block; + height: 15px; + min-width: 15px; + visibility: hidden; +} + +.entry .starred { + visibility: visible; +} + +.entry .title > a, +.site-domain [is='action-link'] { + color: rgb(48, 57, 66); + margin: 2px; + padding: 2px; + /* Focus outlines are rendered differently for each platform. */ +<if expr="is_macosx"> + margin: 4px; + padding: 0; +</if> +<if expr="is_win"> + padding: 0 2px; +</if> + text-decoration: none; +} + +.entry .title > a.to-be-removed { + text-decoration: line-through; +} + +.entry .title > a:hover { + text-decoration: underline; +} + +.fade-out { + -webkit-transition: opacity 200ms; + opacity: 0; +} + +button.menu-button.drop-down { + -webkit-margin-end: 0; + min-width: 12px; + top: 0; +} + +#action-menu > [role=menuitem] { + line-height: 29px; + outline: none; +} + +body:not(.has-results) #results-pagination { + display: none; +} + +#older-button { + float: right; +} + +html[dir='rtl'] #older-button { + float: left; +} + +html[dir='ltr'] #newest-button::before { + /* Left-pointing double angle quotation mark followed by ' '. */ + content: '\00AB\A0'; +} + +html[dir='rtl'] #newest-button::after { + /* ' ' followed by right-pointing double angle quotation mark. */ + content: '\A0\00BB'; +} + +html[dir='ltr'] #newer-button::before, +html[dir='rtl'] #older-button::before { + /* Single left-pointing angle quotation mark followed by ' '. */ + content: '\2039\A0'; +} + +html[dir='ltr'] #older-button::after, +html[dir='rtl'] #newer-button::after { + /* 'nbsp;' followed by single right-pointing angle quotation mark. */ + content: '\A0\203A'; +} + +/* Clear the float to ensure that #results-pagination encloses its children. */ +#results-pagination::after { + clear: both; + content: ''; + display: block; + height: 0; + visibility: hidden; +} + +/* Styles for the action menu of visits that come from other devices, triggered + by setting the "data-devicename" attribute of the menu. */ + +#action-menu[data-devicename]:not([data-devicename='']) { + padding-top: 0; +} + +#action-menu[data-devicename]::before { + background-color: rgb(245, 245, 245); + background-position: 18px center; + background-repeat: no-repeat; + background-size: 24px; + border-bottom: 1px solid rgb(232, 232, 232); + color: rgb(151, 156, 160); + content: attr(data-devicename); + display: block; + font-size: 11px; + line-height: 29px; + margin-bottom: 8px; + padding: 0 19px 0 51px; +} + +#action-menu[data-devicename='']::before { + display: none; +} + +#action-menu[data-devicetype='laptop']::before { + background-image: url(../../../../../ui/webui/resources/images/laptop.svg); +} + +#action-menu[data-devicetype='phone']::before { + background-image: + url(../../../../../ui/webui/resources/images/smartphone.svg); + background-position: 14px center; + padding-left: 43px; +} + +#action-menu[data-devicetype='tablet']::before { + background-image: url(../../../../../ui/webui/resources/images/tablet.svg); +} diff --git a/ios/chrome/app/resources/history/history.html b/ios/chrome/app/resources/history/history.html new file mode 100644 index 0000000..e7b33b8 --- /dev/null +++ b/ios/chrome/app/resources/history/history.html @@ -0,0 +1,115 @@ +<!doctype html> +<html i18n-values="dir:textdirection;lang:language"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1.0, + maximum-scale=1.0, user-scalable=no"> +<title i18n-content="title"></title> +<link rel="stylesheet" href="chrome://resources/css/butter_bar.css"> +<link rel="stylesheet" href="chrome://resources/css/chrome_shared.css"> +<link rel="stylesheet" href="chrome://resources/css/menu.css"> +<link rel="stylesheet" href="chrome://resources/css/menu_button.css"> +<link rel="stylesheet" href="chrome://resources/css/spinner.css"> +<link rel="stylesheet" href="history.css"> +<link rel="stylesheet" href="history_mobile.css"> + +<script src="chrome://resources/js/ios/web_ui.js"></script> +<script src="chrome://resources/js/action_link.js"></script> +<script src="chrome://resources/js/assert.js"></script> +<script src="chrome://resources/js/event_tracker.js"></script> +<script src="chrome://resources/js/util.js"></script> +<script src="chrome://resources/js/cr.js"></script> +<script src="chrome://resources/js/cr/ui.js"></script> +<script src="chrome://resources/js/cr/ui/command.js"></script> +<script src="chrome://resources/js/cr/ui/focus_outline_manager.js"></script> +<script src="chrome://resources/js/cr/ui/focus_row.js"></script> +<script src="chrome://resources/js/cr/ui/focus_grid.js"></script> +<script src="chrome://resources/js/cr/ui/menu_item.js"></script> +<script src="chrome://resources/js/cr/ui/menu.js"></script> +<script src="chrome://resources/js/cr/ui/position_util.js"></script> +<script src="chrome://resources/js/cr/ui/menu_button.js"></script> +<script src="chrome://resources/js/cr/ui/context_menu_button.js"></script> +<script src="chrome://resources/js/cr/event_target.js"></script> +<script src="chrome://resources/js/cr/ui/context_menu_handler.js"></script> + +<script src="chrome://resources/js/load_time_data.js"></script> +<script src="chrome://resources/js/util.js"></script> + +<script src="chrome://history-frame/history.js"></script> +<script src="chrome://history-frame/other_devices.js"></script> + +</head> + +<body> + +<div id="history-page" class="page"> + <div id="scrolling-container"> + <header> + <h1 i18n-content="history"></h1> + <div id="search-form" class="search-field-container"> + <input type="search" id="search-field" + i18n-values="aria-label:searchButton"> + <input type="submit" id="search-button" + i18n-values="value:searchButton" aria-controls="results-header"> + </div> + <div id="filter-controls" hidden> + <div id="range-controls"> + <button id="range-today" i18n-content="rangeToday" + i18n-values="aria-label:rangeToday" disabled></button> + <button id="range-previous" i18n-values="aria-label:rangePrevious" + disabled></button> + <button id="range-next" i18n-values="aria-label:rangeNext" disabled> + </button> + </div> + <div id="timeframe-controls"> + <input type="radio" name="timeframe-filter" value="0" checked + i18n-values="aria-label:rangeAllTime" + aria-controls="results-header"> + <input type="radio" name="timeframe-filter" value="1" + i18n-values="aria-label:rangeWeek" + aria-controls="results-header"> + <input type="radio" name="timeframe-filter" value="2" + i18n-values="aria-label:rangeMonth" + aria-controls="results-header"> + </div> + </div> + </header> + <div id="other-devices" class="other-devices"></div> + <div id="top-container"> + <div id="editing-controls"> + <button id="clear-browsing-data" i18n-content="clearAllHistory"> + </button> + <button id="remove-selected" disabled="disabled" + i18n-content="removeSelected"></button> + </div> + <div id="notification-bar" hidden></div> + </div> + + <div id="results-display"> + <h3 id="results-header" aria-live="polite"></h3> + </div> + <div id="loading-spinner" hidden> + <span id="loading"> + <div id="spinner" class="inline-spinner"></div> + <span i18n-content="loading"></span> + </span> + </div> + <div id="results-pagination"> + <a is="action-link" id="newest-button" i18n-content="newest" hidden></a> + <a is="action-link" id="newer-button" i18n-content="newer" hidden></a> + <a is="action-link" id="older-button" i18n-content="older" hidden></a> + </div> + </div> +</div> + +<command id="remove-visit-command"></command> +<cr-menu id="action-menu" hidden> + <button id="more-from-site" i18n-content="moreFromSite"></button> + <button id="remove-visit" i18n-content="removeFromHistory" + command="#remove-visit-command"></button> +</cr-menu> + +<script src="chrome://history-frame/strings.js"></script> +<script src="chrome://resources/js/i18n_template.js"></script> +</body> +</html> diff --git a/ios/chrome/app/resources/history/history.js b/ios/chrome/app/resources/history/history.js new file mode 100644 index 0000000..c2c27ae --- /dev/null +++ b/ios/chrome/app/resources/history/history.js @@ -0,0 +1,2328 @@ +// Copyright (c) 2012 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/////////////////////////////////////////////////////////////////////////////// +// Globals: +/** @const */ var RESULTS_PER_PAGE = 150; + +// Amount of time between pageviews that we consider a 'break' in browsing, +// measured in milliseconds. +/** @const */ var BROWSING_GAP_TIME = 15 * 60 * 1000; + +// The largest bucket value for UMA histogram, based on entry ID. All entries +// with IDs greater than this will be included in this bucket. +/** @const */ var UMA_MAX_BUCKET_VALUE = 1000; + +// The largest bucket value for a UMA histogram that is a subset of above. +/** @const */ var UMA_MAX_SUBSET_BUCKET_VALUE = 100; + +// TODO(glen): Get rid of these global references, replace with a controller +// or just make the classes own more of the page. +var historyModel; +var historyView; +var pageState; +var selectionAnchor = -1; +var activeVisit = null; + +/** @const */ var Command = cr.ui.Command; +/** @const */ var FocusOutlineManager = cr.ui.FocusOutlineManager; +/** @const */ var Menu = cr.ui.Menu; +/** @const */ var MenuItem = cr.ui.MenuItem; + +/** + * Enum that shows the filtering behavior for a host or URL to a supervised + * user. Must behave like the FilteringBehavior enum from + * supervised_user_url_filter.h. + * @enum {number} + */ +var SupervisedUserFilteringBehavior = { + ALLOW: 0, + WARN: 1, + BLOCK: 2 +}; + +/** + * The type of the history result object. The definition is based on + * chrome/browser/ui/webui/history_ui.cc: + * BrowsingHistoryHandler::HistoryEntry::ToValue() + * @typedef {{allTimestamps: Array<number>, + * blockedVisit: (boolean|undefined), + * dateRelativeDay: (string|undefined), + * dateShort: string, + * dateTimeOfDay: (string|undefined), + * deviceName: string, + * deviceType: string, + * domain: string, + * hostFilteringBehavior: (number|undefined), + * snippet: (string|undefined), + * starred: boolean, + * time: number, + * title: string, + * url: string}} + */ +var HistoryEntry; + +/** + * The type of the history results info object. The definition is based on + * chrome/browser/ui/webui/history_ui.cc: + * BrowsingHistoryHandler::QueryComplete() + * @typedef {{finished: boolean, + * hasSyncedResults: (boolean|undefined), + * queryEndTime: string, + * queryStartTime: string, + * term: string}} + */ +var HistoryQuery; + +/** + * Record an action in UMA. + * @param {string} actionDesc The name of the action to be logged. + */ +function recordUmaAction(actionDesc) { + chrome.send('metricsHandler:recordAction', [actionDesc]); +} + +/** + * Record a histogram value in UMA. If specified value is larger than the max + * bucket value, record the value in the largest bucket. + * @param {string} histogram The name of the histogram to be recorded in. + * @param {number} maxBucketValue The max value for the last histogram bucket. + * @param {number} value The value to record in the histogram. + */ +function recordUmaHistogram(histogram, maxBucketValue, value) { + chrome.send('metricsHandler:recordInHistogram', + [histogram, + ((value > maxBucketValue) ? maxBucketValue : value), + maxBucketValue]); +} + +/////////////////////////////////////////////////////////////////////////////// +// Visit: + +/** + * Class to hold all the information about an entry in our model. + * @param {HistoryEntry} result An object containing the visit's data. + * @param {boolean} continued Whether this visit is on the same day as the + * visit before it. + * @param {HistoryModel} model The model object this entry belongs to. + * @constructor + */ +function Visit(result, continued, model) { + this.model_ = model; + this.title_ = result.title; + this.url_ = result.url; + this.domain_ = result.domain; + this.starred_ = result.starred; + + // These identify the name and type of the device on which this visit + // occurred. They will be empty if the visit occurred on the current device. + this.deviceName = result.deviceName; + this.deviceType = result.deviceType; + + // The ID will be set according to when the visit was displayed, not + // received. Set to -1 to show that it has not been set yet. + this.id_ = -1; + + this.isRendered = false; // Has the visit already been rendered on the page? + + // All the date information is public so that owners can compare properties of + // two items easily. + + this.date = new Date(result.time); + + // See comment in BrowsingHistoryHandler::QueryComplete - we won't always + // get all of these. + this.dateRelativeDay = result.dateRelativeDay || ''; + this.dateTimeOfDay = result.dateTimeOfDay || ''; + this.dateShort = result.dateShort || ''; + + // Shows the filtering behavior for that host (only used for supervised + // users). + // A value of |SupervisedUserFilteringBehavior.ALLOW| is not displayed so it + // is used as the default value. + this.hostFilteringBehavior = SupervisedUserFilteringBehavior.ALLOW; + if (typeof result.hostFilteringBehavior != 'undefined') + this.hostFilteringBehavior = result.hostFilteringBehavior; + + this.blockedVisit = result.blockedVisit || false; + + // Whether this is the continuation of a previous day. + this.continued = continued; + + this.allTimestamps = result.allTimestamps; +} + +// Visit, public: ------------------------------------------------------------- + +/** + * Returns a dom structure for a browse page result or a search page result. + * @param {Object} propertyBag A bag of configuration properties, false by + * default: + * - isSearchResult: Whether or not the result is a search result. + * - addTitleFavicon: Whether or not the favicon should be added. + * - useMonthDate: Whether or not the full date should be inserted (used for + * monthly view). + * @return {Node} A DOM node to represent the history entry or search result. + */ +Visit.prototype.getResultDOM = function(propertyBag) { + var isSearchResult = propertyBag.isSearchResult || false; + var addTitleFavicon = propertyBag.addTitleFavicon || false; + var useMonthDate = propertyBag.useMonthDate || false; + var focusless = propertyBag.focusless || false; + var node = createElementWithClassName('li', 'entry'); + var time = createElementWithClassName('span', 'time'); + var entryBox = createElementWithClassName('div', 'entry-box'); + var domain = createElementWithClassName('div', 'domain'); + + this.id_ = this.model_.getNextVisitId(); + var self = this; + + // Only create the checkbox if it can be used to delete an entry. + if (this.model_.editingEntriesAllowed) { + var checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.id = 'checkbox-' + this.id_; + checkbox.time = this.date.getTime(); + checkbox.setAttribute('aria-label', loadTimeData.getStringF( + 'entrySummary', + this.dateTimeOfDay, + this.starred_ ? loadTimeData.getString('bookmarked') : '', + this.title_, + this.domain_)); + checkbox.addEventListener('click', checkboxClicked); + entryBox.appendChild(checkbox); + + if (focusless) + checkbox.tabIndex = -1; + } + + // Keep track of the drop down that triggered the menu, so we know + // which element to apply the command to. + // TODO(dubroy): Ideally we'd use 'activate', but MenuButton swallows it. + var setActiveVisit = function(e) { + activeVisit = self; + var menu = $('action-menu'); + menu.dataset.devicename = self.deviceName; + menu.dataset.devicetype = self.deviceType; + }; + domain.textContent = this.domain_; + + entryBox.appendChild(time); + + var bookmarkSection = createElementWithClassName( + 'button', 'bookmark-section custom-appearance'); + if (this.starred_) { + bookmarkSection.title = loadTimeData.getString('removeBookmark'); + bookmarkSection.classList.add('starred'); + bookmarkSection.addEventListener('click', function f(e) { + recordUmaAction('HistoryPage_BookmarkStarClicked'); + chrome.send('removeBookmark', [self.url_]); + + this.model_.getView().onBeforeUnstarred(this); + bookmarkSection.classList.remove('starred'); + this.model_.getView().onAfterUnstarred(this); + + bookmarkSection.removeEventListener('click', f); + e.preventDefault(); + }.bind(this)); + } + + if (focusless) + bookmarkSection.tabIndex = -1; + + entryBox.appendChild(bookmarkSection); + + var visitEntryWrapper = /** @type {HTMLElement} */( + entryBox.appendChild(document.createElement('div'))); + if (addTitleFavicon || this.blockedVisit) + visitEntryWrapper.classList.add('visit-entry'); + if (this.blockedVisit) { + visitEntryWrapper.classList.add('blocked-indicator'); + visitEntryWrapper.appendChild(this.getVisitAttemptDOM_()); + } else { + var title = visitEntryWrapper.appendChild( + this.getTitleDOM_(isSearchResult)); + + if (addTitleFavicon) + this.addFaviconToElement_(visitEntryWrapper); + + if (focusless) + title.querySelector('a').tabIndex = -1; + + visitEntryWrapper.appendChild(domain); + } + + if (this.model_.editingEntriesAllowed) { + var removeButton = createElementWithClassName('button', 'remove-entry'); + removeButton.setAttribute('aria-label', + loadTimeData.getString('removeFromHistory')); + removeButton.classList.add('custom-appearance'); + removeButton.addEventListener( + 'click', this.removeEntryFromHistory_.bind(this)); + entryBox.appendChild(removeButton); + + // Support clicking anywhere inside the entry box. + entryBox.addEventListener('click', function(e) { + if (!e.defaultPrevented) { + self.titleLink.focus(); + self.titleLink.click(); + } + }); + } + + // Let the entryBox be styled appropriately when it contains keyboard focus. + entryBox.addEventListener('focus', function() { + this.classList.add('contains-focus'); + }, true); + entryBox.addEventListener('blur', function() { + this.classList.remove('contains-focus'); + }, true); + + var entryBoxContainer = + createElementWithClassName('div', 'entry-box-container'); + node.appendChild(entryBoxContainer); + entryBoxContainer.appendChild(entryBox); + + if (isSearchResult || useMonthDate) { + // Show the day instead of the time. + time.appendChild(document.createTextNode(this.dateShort)); + } else { + time.appendChild(document.createTextNode(this.dateTimeOfDay)); + } + + this.domNode_ = node; + node.visit = this; + + return node; +}; + +/** + * Remove this visit from the history. + */ +Visit.prototype.removeFromHistory = function() { + recordUmaAction('HistoryPage_EntryMenuRemoveFromHistory'); + this.model_.removeVisitsFromHistory([this], function() { + this.model_.getView().removeVisit(this); + }.bind(this)); +}; + +// Closure Compiler doesn't support Object.defineProperty(). +// https://github.com/google/closure-compiler/issues/302 +Object.defineProperty(Visit.prototype, 'checkBox', { + get: /** @this {Visit} */function() { + return this.domNode_.querySelector('input[type=checkbox]'); + }, +}); + +Object.defineProperty(Visit.prototype, 'bookmarkStar', { + get: /** @this {Visit} */function() { + return this.domNode_.querySelector('.bookmark-section.starred'); + }, +}); + +Object.defineProperty(Visit.prototype, 'titleLink', { + get: /** @this {Visit} */function() { + return this.domNode_.querySelector('.title a'); + }, +}); + +Object.defineProperty(Visit.prototype, 'dropDown', { + get: /** @this {Visit} */function() { + return this.domNode_.querySelector('button.drop-down'); + }, +}); + +// Visit, private: ------------------------------------------------------------ + +/** + * Add child text nodes to a node such that occurrences of the specified text is + * highlighted. + * @param {Node} node The node under which new text nodes will be made as + * children. + * @param {string} content Text to be added beneath |node| as one or more + * text nodes. + * @param {string} highlightText Occurences of this text inside |content| will + * be highlighted. + * @private + */ +Visit.prototype.addHighlightedText_ = function(node, content, highlightText) { + var i = 0; + if (highlightText) { + var re = new RegExp(Visit.pregQuote_(highlightText), 'gim'); + var match; + while (match = re.exec(content)) { + if (match.index > i) + node.appendChild(document.createTextNode(content.slice(i, + match.index))); + i = re.lastIndex; + // Mark the highlighted text in bold. + var b = document.createElement('b'); + b.textContent = content.substring(match.index, i); + node.appendChild(b); + } + } + if (i < content.length) + node.appendChild(document.createTextNode(content.slice(i))); +}; + +/** + * Returns the DOM element containing a link on the title of the URL for the + * current visit. + * @param {boolean} isSearchResult Whether or not the entry is a search result. + * @return {Element} DOM representation for the title block. + * @private + */ +Visit.prototype.getTitleDOM_ = function(isSearchResult) { + var node = createElementWithClassName('div', 'title'); + var link = document.createElement('a'); + link.href = this.url_; + link.id = 'id-' + this.id_; + link.target = '_top'; + var integerId = parseInt(this.id_, 10); + link.addEventListener('click', function() { + recordUmaAction('HistoryPage_EntryLinkClick'); + // Record the ID of the entry to signify how many entries are above this + // link on the page. + recordUmaHistogram('HistoryPage.ClickPosition', + UMA_MAX_BUCKET_VALUE, + integerId); + if (integerId <= UMA_MAX_SUBSET_BUCKET_VALUE) { + recordUmaHistogram('HistoryPage.ClickPositionSubset', + UMA_MAX_SUBSET_BUCKET_VALUE, + integerId); + } + }); + link.addEventListener('contextmenu', function() { + recordUmaAction('HistoryPage_EntryLinkRightClick'); + }); + + if (isSearchResult) { + link.addEventListener('click', function() { + recordUmaAction('HistoryPage_SearchResultClick'); + }); + } + + // Add a tooltip, since it might be ellipsized. + // TODO(dubroy): Find a way to show the tooltip only when necessary. + link.title = this.title_; + + this.addHighlightedText_(link, this.title_, this.model_.getSearchText()); + node.appendChild(link); + + return node; +}; + +/** + * Returns the DOM element containing the text for a blocked visit attempt. + * @return {Element} DOM representation of the visit attempt. + * @private + */ +Visit.prototype.getVisitAttemptDOM_ = function() { + var node = createElementWithClassName('div', 'title'); + node.innerHTML = loadTimeData.getStringF('blockedVisitText', + this.url_, + this.id_, + this.domain_); + return node; +}; + +/** + * Set the favicon for an element. + * @param {Element} el The DOM element to which to add the icon. + * @private + */ +Visit.prototype.addFaviconToElement_ = function(el) { + var url = getFaviconImageSet(this.url_, 32, 'touch-icon'); + el.style.backgroundImage = url; + chrome.requestFavicon(el, url); +}; + +/** + * Launch a search for more history entries from the same domain. + * @private + */ +Visit.prototype.showMoreFromSite_ = function() { + recordUmaAction('HistoryPage_EntryMenuShowMoreFromSite'); + historyView.setSearch(this.domain_); + $('search-field').focus(); +}; + +/** + * @param {Event} e A keydown event to handle. + * @private + */ +Visit.prototype.handleKeydown_ = function(e) { + // Delete or Backspace should delete the entry if allowed. + if (e.keyIdentifier == 'U+0008' || e.keyIdentifier == 'U+007F') + this.removeEntryFromHistory_(e); +}; + +/** + * @param {Event} event A mousedown event. + * @private + */ +Visit.prototype.handleMousedown_ = function(event) { + // Prevent text selection when shift-clicking to select multiple entries. + if (event.shiftKey) { + event.preventDefault(); + + var target = assertInstanceof(event.target, HTMLElement); + if (this.model_.getView().isInFocusGrid(target)) + target.focus(); + } +}; + +/** + * Removes a history entry on click or keydown and finds a new entry to focus. + * @param {Event} e A click or keydown event. + * @private + */ +Visit.prototype.removeEntryFromHistory_ = function(e) { + if (!this.model_.deletingHistoryAllowed || this.model_.isDeletingVisits() || + this.domNode_.classList.contains('fade-out')) { + return; + } + + this.model_.getView().onBeforeRemove(this); + this.removeFromHistory(); + e.preventDefault(); +}; + +// Visit, private, static: ---------------------------------------------------- + +/** + * Quote a string so it can be used in a regular expression. + * @param {string} str The source string. + * @return {string} The escaped string. + * @private + */ +Visit.pregQuote_ = function(str) { + return str.replace(/([\\\.\+\*\?\[\^\]\$\(\)\{\}\=\!\<\>\|\:])/g, '\\$1'); +}; + +/////////////////////////////////////////////////////////////////////////////// +// HistoryModel: + +/** + * Global container for history data. Future optimizations might include + * allowing the creation of a HistoryModel for each search string, allowing + * quick flips back and forth between results. + * + * The history model is based around pages, and only fetching the data to + * fill the currently requested page. This is somewhat dependent on the view, + * and so future work may wish to change history model to operate on + * timeframe (day or week) based containers. + * + * @constructor + */ +function HistoryModel() { + this.clearModel_(); +} + +// HistoryModel, Public: ------------------------------------------------------ + +/** @enum {number} */ +HistoryModel.Range = { + ALL_TIME: 0, + WEEK: 1, + MONTH: 2 +}; + +/** + * Sets our current view that is called when the history model changes. + * @param {HistoryView} view The view to set our current view to. + */ +HistoryModel.prototype.setView = function(view) { + this.view_ = view; +}; + + +/** + * @return {HistoryView|undefined} Returns the view for this model (if set). + */ +HistoryModel.prototype.getView = function() { + return this.view_; +}; + +/** + * Reload our model with the current parameters. + */ +HistoryModel.prototype.reload = function() { + // Save user-visible state, clear the model, and restore the state. + var search = this.searchText_; + var page = this.requestedPage_; + var range = this.rangeInDays_; + var offset = this.offset_; + var groupByDomain = this.groupByDomain_; + + this.clearModel_(); + this.searchText_ = search; + this.requestedPage_ = page; + this.rangeInDays_ = range; + this.offset_ = offset; + this.groupByDomain_ = groupByDomain; + this.queryHistory_(); +}; + +/** + * @return {string} The current search text. + */ +HistoryModel.prototype.getSearchText = function() { + return this.searchText_; +}; + +/** + * Tell the model that the view will want to see the current page. When + * the data becomes available, the model will call the view back. + * @param {number} page The page we want to view. + */ +HistoryModel.prototype.requestPage = function(page) { + this.requestedPage_ = page; + this.updateSearch_(); +}; + +/** + * Receiver for history query. + * @param {HistoryQuery} info An object containing information about the query. + * @param {Array<HistoryEntry>} results A list of results. + */ +HistoryModel.prototype.addResults = function(info, results) { + // If no requests are in flight then this was an old request so we drop the + // results. Double check the search term as well. + if (!this.inFlight_ || info.term != this.searchText_) + return; + + $('loading-spinner').hidden = true; + this.inFlight_ = false; + this.isQueryFinished_ = info.finished; + this.queryStartTime = info.queryStartTime; + this.queryEndTime = info.queryEndTime; + + var lastVisit = this.visits_.slice(-1)[0]; + var lastDay = lastVisit ? lastVisit.dateRelativeDay : null; + + for (var i = 0, result; result = results[i]; i++) { + var thisDay = result.dateRelativeDay; + var isSameDay = lastDay == thisDay; + this.visits_.push(new Visit(result, isSameDay, this)); + lastDay = thisDay; + } + + if (loadTimeData.getBoolean('isUserSignedIn')) { + var message = loadTimeData.getString( + info.hasSyncedResults ? 'hasSyncedResults' : 'noSyncedResults'); + this.view_.showNotification(message); + } + + this.updateSearch_(); +}; + +/** + * @return {number} The number of visits in the model. + */ +HistoryModel.prototype.getSize = function() { + return this.visits_.length; +}; + +/** + * Get a list of visits between specified index positions. + * @param {number} start The start index. + * @param {number} end The end index. + * @return {Array<Visit>} A list of visits. + */ +HistoryModel.prototype.getNumberedRange = function(start, end) { + return this.visits_.slice(start, end); +}; + +/** + * Return true if there are more results beyond the current page. + * @return {boolean} true if the there are more results, otherwise false. + */ +HistoryModel.prototype.hasMoreResults = function() { + return this.haveDataForPage_(this.requestedPage_ + 1) || + !this.isQueryFinished_; +}; + +/** + * Removes a list of visits from the history, and calls |callback| when the + * removal has successfully completed. + * @param {Array<Visit>} visits The visits to remove. + * @param {Function} callback The function to call after removal succeeds. + */ +HistoryModel.prototype.removeVisitsFromHistory = function(visits, callback) { + assert(this.deletingHistoryAllowed); + + var toBeRemoved = []; + for (var i = 0; i < visits.length; i++) { + toBeRemoved.push({ + url: visits[i].url_, + timestamps: visits[i].allTimestamps + }); + } + + this.deleteCompleteCallback_ = callback; + chrome.send('removeVisits', toBeRemoved); +}; + +/** @return {boolean} Whether the model is currently deleting a visit. */ +HistoryModel.prototype.isDeletingVisits = function() { + return !!this.deleteCompleteCallback_; +}; + +/** + * Called when visits have been succesfully removed from the history. + */ +HistoryModel.prototype.deleteComplete = function() { + // Call the callback, with 'this' undefined inside the callback. + this.deleteCompleteCallback_.call(); + this.deleteCompleteCallback_ = null; +}; + +// Getter and setter for HistoryModel.rangeInDays_. +Object.defineProperty(HistoryModel.prototype, 'rangeInDays', { + get: /** @this {HistoryModel} */function() { + return this.rangeInDays_; + }, + set: /** @this {HistoryModel} */function(range) { + this.rangeInDays_ = range; + } +}); + +/** + * Getter and setter for HistoryModel.offset_. The offset moves the current + * query 'window' |range| days behind. As such for range set to WEEK an offset + * of 0 refers to the last 7 days, an offset of 1 refers to the 7 day period + * that ended 7 days ago, etc. For MONTH an offset of 0 refers to the current + * calendar month, 1 to the previous one, etc. + */ +Object.defineProperty(HistoryModel.prototype, 'offset', { + get: /** @this {HistoryModel} */function() { + return this.offset_; + }, + set: /** @this {HistoryModel} */function(offset) { + this.offset_ = offset; + } +}); + +// Setter for HistoryModel.requestedPage_. +Object.defineProperty(HistoryModel.prototype, 'requestedPage', { + set: /** @this {HistoryModel} */function(page) { + this.requestedPage_ = page; + } +}); + +/** + * Removes |visit| from this model. + * @param {Visit} visit A visit to remove. + */ +HistoryModel.prototype.removeVisit = function(visit) { + var index = this.visits_.indexOf(visit); + if (index >= 0) + this.visits_.splice(index, 1); +}; + +/** + * Automatically generates a new visit ID. + * @return {number} The next visit ID. + */ +HistoryModel.prototype.getNextVisitId = function() { + return this.nextVisitId_++; +}; + +// HistoryModel, Private: ----------------------------------------------------- + +/** + * Clear the history model. + * @private + */ +HistoryModel.prototype.clearModel_ = function() { + this.inFlight_ = false; // Whether a query is inflight. + this.searchText_ = ''; + // Whether this user is a supervised user. + this.isSupervisedProfile = loadTimeData.getBoolean('isSupervisedProfile'); + this.deletingHistoryAllowed = loadTimeData.getBoolean('allowDeletingHistory'); + + // Only create checkboxes for editing entries if they can be used either to + // delete an entry or to block/allow it. + this.editingEntriesAllowed = this.deletingHistoryAllowed; + + // Flag to show that the results are grouped by domain or not. + this.groupByDomain_ = false; + + this.visits_ = []; // Date-sorted list of visits (most recent first). + this.nextVisitId_ = 0; + selectionAnchor = -1; + + // The page that the view wants to see - we only fetch slightly past this + // point. If the view requests a page that we don't have data for, we try + // to fetch it and call back when we're done. + this.requestedPage_ = 0; + + // The range of history to view or search over. + this.rangeInDays_ = HistoryModel.Range.ALL_TIME; + + // Skip |offset_| * weeks/months from the begining. + this.offset_ = 0; + + // Keeps track of whether or not there are more results available than are + // currently held in |this.visits_|. + this.isQueryFinished_ = false; + + if (this.view_) + this.view_.clear_(); +}; + +/** + * Figure out if we need to do more queries to fill the currently requested + * page. If we think we can fill the page, call the view and let it know + * we're ready to show something. This only applies to the daily time-based + * view. + * @private + */ +HistoryModel.prototype.updateSearch_ = function() { + var doneLoading = this.rangeInDays_ != HistoryModel.Range.ALL_TIME || + this.isQueryFinished_ || + this.canFillPage_(this.requestedPage_); + + // Try to fetch more results if more results can arrive and the page is not + // full. + if (!doneLoading && !this.inFlight_) + this.queryHistory_(); + + // Show the result or a message if no results were returned. + this.view_.onModelReady(doneLoading); +}; + +/** + * Query for history, either for a search or time-based browsing. + * @private + */ +HistoryModel.prototype.queryHistory_ = function() { + var maxResults = + (this.rangeInDays_ == HistoryModel.Range.ALL_TIME) ? RESULTS_PER_PAGE : 0; + + // If there are already some visits, pick up the previous query where it + // left off. + var lastVisit = this.visits_.slice(-1)[0]; + var endTime = lastVisit ? lastVisit.date.getTime() : 0; + + $('loading-spinner').hidden = false; + this.inFlight_ = true; + chrome.send('queryHistory', + [this.searchText_, this.offset_, this.rangeInDays_, endTime, maxResults]); +}; + +/** + * Check to see if we have data for the given page. + * @param {number} page The page number. + * @return {boolean} Whether we have any data for the given page. + * @private + */ +HistoryModel.prototype.haveDataForPage_ = function(page) { + return page * RESULTS_PER_PAGE < this.getSize(); +}; + +/** + * Check to see if we have data to fill the given page. + * @param {number} page The page number. + * @return {boolean} Whether we have data to fill the page. + * @private + */ +HistoryModel.prototype.canFillPage_ = function(page) { + return ((page + 1) * RESULTS_PER_PAGE <= this.getSize()); +}; + +/** + * Gets whether we are grouped by domain. + * @return {boolean} Whether the results are grouped by domain. + */ +HistoryModel.prototype.getGroupByDomain = function() { + return this.groupByDomain_; +}; + +/////////////////////////////////////////////////////////////////////////////// +// HistoryFocusRow: + +/** + * Provides an implementation for a single column grid. + * @param {!Element} root + * @param {Node} boundary + * @constructor + * @extends {cr.ui.FocusRow} + */ +function HistoryFocusRow(root, boundary) { + cr.ui.FocusRow.call(this, root, boundary); + + // None of these are guaranteed to exist in all versions of the UI. + this.addItem('checkbox', '.entry-box input'); + this.addItem('checkbox', '.domain-checkbox'); + this.addItem('star', '.bookmark-section.starred'); + this.addItem('domain', '[is="action-link"]'); + this.addItem('title', '.title a'); + this.addItem('menu', '.drop-down'); +} + +HistoryFocusRow.prototype = { + __proto__: cr.ui.FocusRow.prototype, + + /** @override */ + getCustomEquivalent: function(sampleElement) { + var equivalent; + + switch (this.getTypeForElement(sampleElement)) { + case 'star': + equivalent = this.getFirstFocusable('title') || + this.getFirstFocusable('domain'); + break; + case 'domain': + equivalent = this.getFirstFocusable('title'); + break; + case 'title': + equivalent = this.getFirstFocusable('domain'); + break; + case 'menu': + equivalent = this.getFocusableElements().slice(-1)[0]; + break; + } + + return equivalent || + cr.ui.FocusRow.prototype.getCustomEquivalent.call(this, sampleElement); + }, +}; + +/////////////////////////////////////////////////////////////////////////////// +// HistoryView: + +/** + * Functions and state for populating the page with HTML. This should one-day + * contain the view and use event handlers, rather than pushing HTML out and + * getting called externally. + * @param {HistoryModel} model The model backing this view. + * @constructor + */ +function HistoryView(model) { + this.editButtonTd_ = $('edit-button'); + this.editingControlsDiv_ = $('editing-controls'); + this.resultDiv_ = $('results-display'); + this.focusGrid_ = new cr.ui.FocusGrid(); + this.pageDiv_ = $('results-pagination'); + this.model_ = model; + this.pageIndex_ = 0; + this.lastDisplayed_ = []; + + this.model_.setView(this); + + this.currentVisits_ = []; + + // If there is no search button, use the search button label as placeholder + // text in the search field. + if ($('search-button').offsetWidth == 0) + $('search-field').placeholder = $('search-button').value; + + var self = this; + + $('clear-browsing-data').addEventListener('click', openClearBrowsingData); + $('remove-selected').addEventListener('click', removeItems); + + // Add handlers for the page navigation buttons at the bottom. + $('newest-button').addEventListener('click', function() { + recordUmaAction('HistoryPage_NewestHistoryClick'); + self.setPage(0); + }); + $('newer-button').addEventListener('click', function() { + recordUmaAction('HistoryPage_NewerHistoryClick'); + self.setPage(self.pageIndex_ - 1); + }); + $('older-button').addEventListener('click', function() { + recordUmaAction('HistoryPage_OlderHistoryClick'); + self.setPage(self.pageIndex_ + 1); + }); + + $('timeframe-controls').onchange = function(e) { + var value = parseInt(e.target.value, 10); + self.setRangeInDays(/** @type {HistoryModel.Range<number>} */(value)); + }; + + $('range-previous').addEventListener('click', function(e) { + if (self.getRangeInDays() == HistoryModel.Range.ALL_TIME) + self.setPage(self.pageIndex_ + 1); + else + self.setOffset(self.getOffset() + 1); + }); + $('range-next').addEventListener('click', function(e) { + if (self.getRangeInDays() == HistoryModel.Range.ALL_TIME) + self.setPage(self.pageIndex_ - 1); + else + self.setOffset(self.getOffset() - 1); + }); + $('range-today').addEventListener('click', function(e) { + if (self.getRangeInDays() == HistoryModel.Range.ALL_TIME) + self.setPage(0); + else + self.setOffset(0); + }); +} + +// HistoryView, public: ------------------------------------------------------- +/** + * Do a search on a specific term. + * @param {string} term The string to search for. + */ +HistoryView.prototype.setSearch = function(term) { + window.scrollTo(0, 0); + this.setPageState(term, 0, this.getRangeInDays(), this.getOffset()); +}; + +/** + * Reload the current view. + */ +HistoryView.prototype.reload = function() { + this.model_.reload(); + this.updateSelectionEditButtons(); + this.updateRangeButtons_(); +}; + +/** + * Sets all the parameters for the history page and then reloads the view to + * update the results. + * @param {string} searchText The search string to set. + * @param {number} page The page to be viewed. + * @param {HistoryModel.Range} range The range to view or search over. + * @param {number} offset Set the begining of the query to the specific offset. + */ +HistoryView.prototype.setPageState = function(searchText, page, range, offset) { + this.clear_(); + this.model_.searchText_ = searchText; + this.pageIndex_ = page; + this.model_.requestedPage_ = page; + this.model_.rangeInDays_ = range; + this.model_.groupByDomain_ = false; + if (range != HistoryModel.Range.ALL_TIME) + this.model_.groupByDomain_ = true; + this.model_.offset_ = offset; + this.reload(); + pageState.setUIState(this.model_.getSearchText(), + this.pageIndex_, + this.getRangeInDays(), + this.getOffset()); +}; + +/** + * Switch to a specified page. + * @param {number} page The page we wish to view. + */ +HistoryView.prototype.setPage = function(page) { + // TODO(sergiu): Move this function to setPageState as well and see why one + // of the tests fails when using setPageState. + this.clear_(); + this.pageIndex_ = parseInt(page, 10); + window.scrollTo(0, 0); + this.model_.requestPage(page); + pageState.setUIState(this.model_.getSearchText(), + this.pageIndex_, + this.getRangeInDays(), + this.getOffset()); +}; + +/** + * @return {number} The page number being viewed. + */ +HistoryView.prototype.getPage = function() { + return this.pageIndex_; +}; + +/** + * Set the current range for grouped results. + * @param {HistoryModel.Range} range The number of days to which the range + * should be set. + */ +HistoryView.prototype.setRangeInDays = function(range) { + // Set the range, offset and reset the page. + this.setPageState(this.model_.getSearchText(), 0, range, 0); +}; + +/** + * Get the current range in days. + * @return {HistoryModel.Range} Current range in days from the model. + */ +HistoryView.prototype.getRangeInDays = function() { + return this.model_.rangeInDays; +}; + +/** + * Set the current offset for grouped results. + * @param {number} offset Offset to set. + */ +HistoryView.prototype.setOffset = function(offset) { + // If there is another query already in flight wait for that to complete. + if (this.model_.inFlight_) + return; + this.setPageState(this.model_.getSearchText(), + this.pageIndex_, + this.getRangeInDays(), + offset); +}; + +/** + * Get the current offset. + * @return {number} Current offset from the model. + */ +HistoryView.prototype.getOffset = function() { + return this.model_.offset; +}; + +/** + * Callback for the history model to let it know that it has data ready for us + * to view. + * @param {boolean} doneLoading Whether the current request is complete. + */ +HistoryView.prototype.onModelReady = function(doneLoading) { + this.displayResults_(doneLoading); + + // Allow custom styling based on whether there are any results on the page. + // To make this easier, add a class to the body if there are any results. + var hasResults = this.model_.visits_.length > 0; + document.body.classList.toggle('has-results', hasResults); + + this.updateFocusGrid_(); + this.updateNavBar_(); + + // Hide the search field if it is empty and there are no results. + var isSearch = this.model_.getSearchText().length > 0; + $('search-field').hidden = !(hasResults || isSearch); +}; + +/** + * Enables or disables the buttons that control editing entries depending on + * whether there are any checked boxes. + */ +HistoryView.prototype.updateSelectionEditButtons = function() { + if (loadTimeData.getBoolean('allowDeletingHistory')) { + var anyChecked = document.querySelector('.entry input:checked') != null; + $('remove-selected').disabled = !anyChecked; + } else { + $('remove-selected').disabled = true; + } +}; + +/** + * Shows the notification bar at the top of the page with |innerHTML| as its + * content. + * @param {string} innerHTML The HTML content of the warning. + * @param {boolean} isWarning If true, style the notification as a warning. + */ +HistoryView.prototype.showNotification = function(innerHTML, isWarning) { + var bar = $('notification-bar'); + bar.innerHTML = innerHTML; + bar.hidden = false; + if (isWarning) + bar.classList.add('warning'); + else + bar.classList.remove('warning'); + + // Make sure that any links in the HTML are targeting the top level. + var links = bar.querySelectorAll('a'); + for (var i = 0; i < links.length; i++) + links[i].target = '_top'; + + this.positionNotificationBar(); +}; + +/** + * @param {Visit} visit The visit about to be removed from this view. + */ +HistoryView.prototype.onBeforeRemove = function(visit) { + assert(this.currentVisits_.indexOf(visit) >= 0); + + var rowIndex = this.focusGrid_.getRowIndexForTarget(document.activeElement); + if (rowIndex == -1) + return; + + var rowToFocus = this.focusGrid_.rows[rowIndex + 1] || + this.focusGrid_.rows[rowIndex - 1]; + if (rowToFocus) + rowToFocus.getEquivalentElement(document.activeElement).focus(); +}; + +/** @param {Visit} visit The visit about to be unstarred. */ +HistoryView.prototype.onBeforeUnstarred = function(visit) { + assert(this.currentVisits_.indexOf(visit) >= 0); + assert(visit.bookmarkStar == document.activeElement); + + var rowIndex = this.focusGrid_.getRowIndexForTarget(document.activeElement); + var row = this.focusGrid_.rows[rowIndex]; + + // Focus the title or domain when the bookmarked star is removed because the + // star will no longer be focusable. + row.root.querySelector('[focus-type=title], [focus-type=domain]').focus(); +}; + +/** @param {Visit} visit The visit that was just unstarred. */ +HistoryView.prototype.onAfterUnstarred = function(visit) { + this.updateFocusGrid_(); +}; + +/** + * Removes a single entry from the view. Also removes gaps before and after + * entry if necessary. + * @param {Visit} visit The visit to be removed. + */ +HistoryView.prototype.removeVisit = function(visit) { + var entry = visit.domNode_; + var previousEntry = entry.previousSibling; + var nextEntry = entry.nextSibling; + var toRemove = [entry]; + + // If there is no previous entry, and the next entry is a gap, remove it. + if (!previousEntry && nextEntry && nextEntry.classList.contains('gap')) + toRemove.push(nextEntry); + + // If there is no next entry, and the previous entry is a gap, remove it. + if (!nextEntry && previousEntry && previousEntry.classList.contains('gap')) + toRemove.push(previousEntry); + + // If both the next and previous entries are gaps, remove the next one. + if (nextEntry && nextEntry.classList.contains('gap') && + previousEntry && previousEntry.classList.contains('gap')) { + toRemove.push(nextEntry); + } + + // If removing the last entry on a day, remove the entire day. + var dayResults = findAncestorByClass(entry, 'day-results'); + if (dayResults && dayResults.querySelectorAll('.entry').length <= 1) { + toRemove.push(dayResults.previousSibling); // Remove the 'h3'. + toRemove.push(dayResults); + } + + // Callback to be called when each node has finished animating. It detects + // when all the animations have completed. + function onRemove() { + for (var i = 0; i < toRemove.length; ++i) { + if (toRemove[i].parentNode) + return; + } + onEntryRemoved(); + } + + // Kick off the removal process. + for (var i = 0; i < toRemove.length; ++i) { + removeNode(toRemove[i], onRemove, this); + } + this.updateFocusGrid_(); + + var index = this.currentVisits_.indexOf(visit); + if (index >= 0) + this.currentVisits_.splice(index, 1); + + this.model_.removeVisit(visit); +}; + +/** + * Called when an individual history entry has been removed from the page. + * This will only be called when all the elements affected by the deletion + * have been removed from the DOM and the animations have completed. + */ +HistoryView.prototype.onEntryRemoved = function() { + this.updateSelectionEditButtons(); + + if (this.model_.getSize() == 0) { + this.clear_(); + this.onModelReady(true); // Shows "No entries" message. + } +}; + +/** + * Adjusts the position of the notification bar based on the size of the page. + */ +HistoryView.prototype.positionNotificationBar = function() { + var bar = $('notification-bar'); + + // If the bar does not fit beside the editing controls, put it into the + // overflow state. + if (bar.getBoundingClientRect().top >= + $('editing-controls').getBoundingClientRect().bottom) { + bar.classList.add('alone'); + } else { + bar.classList.remove('alone'); + } +}; + +/** + * @param {Element} el An element to look for. + * @return {boolean} Whether |el| is in |this.focusGrid_|. + */ +HistoryView.prototype.isInFocusGrid = function(el) { + return this.focusGrid_.getRowIndexForTarget(el) != -1; +}; + +// HistoryView, private: ------------------------------------------------------ + +/** + * Clear the results in the view. Since we add results piecemeal, we need + * to clear them out when we switch to a new page or reload. + * @private + */ +HistoryView.prototype.clear_ = function() { + var alertOverlay = $('alertOverlay'); + if (alertOverlay && alertOverlay.classList.contains('showing')) + hideConfirmationOverlay(); + + // Remove everything but <h3 id="results-header"> (the first child). + while (this.resultDiv_.children.length > 1) { + this.resultDiv_.removeChild(this.resultDiv_.lastElementChild); + } + $('results-header').textContent = ''; + + this.currentVisits_.forEach(function(visit) { + visit.isRendered = false; + }); + this.currentVisits_ = []; + + document.body.classList.remove('has-results'); +}; + +/** + * Record that the given visit has been rendered. + * @param {Visit} visit The visit that was rendered. + * @private + */ +HistoryView.prototype.setVisitRendered_ = function(visit) { + visit.isRendered = true; + this.currentVisits_.push(visit); +}; + +/** + * Generates and adds the grouped visits DOM for a certain domain. This + * includes the clickable arrow and domain name and the visit entries for + * that domain. + * @param {Element} results DOM object to which to add the elements. + * @param {string} domain Current domain name. + * @param {Array} domainVisits Array of visits for this domain. + * @private + */ +HistoryView.prototype.getGroupedVisitsDOM_ = function( + results, domain, domainVisits) { + // Add a new domain entry. + var siteResults = results.appendChild( + createElementWithClassName('li', 'site-entry')); + + var siteDomainWrapper = siteResults.appendChild( + createElementWithClassName('div', 'site-domain-wrapper')); + // Make a row that will contain the arrow, the favicon and the domain. + var siteDomainRow = siteDomainWrapper.appendChild( + createElementWithClassName('div', 'site-domain-row')); + + if (this.model_.editingEntriesAllowed) { + var siteDomainCheckbox = + createElementWithClassName('input', 'domain-checkbox'); + + siteDomainCheckbox.type = 'checkbox'; + siteDomainCheckbox.addEventListener('click', domainCheckboxClicked); + siteDomainCheckbox.domain_ = domain; + siteDomainCheckbox.setAttribute('aria-label', domain); + siteDomainRow.appendChild(siteDomainCheckbox); + } + + var siteArrow = siteDomainRow.appendChild( + createElementWithClassName('div', 'site-domain-arrow')); + var siteDomain = siteDomainRow.appendChild( + createElementWithClassName('div', 'site-domain')); + var siteDomainLink = siteDomain.appendChild(new ActionLink); + siteDomainLink.textContent = domain; + var numberOfVisits = createElementWithClassName('span', 'number-visits'); + var domainElement = document.createElement('span'); + + numberOfVisits.textContent = loadTimeData.getStringF('numberVisits', + domainVisits.length); + siteDomain.appendChild(numberOfVisits); + + domainVisits[0].addFaviconToElement_(siteDomain); + + siteDomainWrapper.addEventListener( + 'click', this.toggleGroupedVisits_.bind(this)); + + if (this.model_.isSupervisedProfile) { + siteDomainRow.appendChild( + getFilteringStatusDOM(domainVisits[0].hostFilteringBehavior)); + } + + siteResults.appendChild(siteDomainWrapper); + var resultsList = siteResults.appendChild( + createElementWithClassName('ol', 'site-results')); + resultsList.classList.add('grouped'); + + // Collapse until it gets toggled. + resultsList.style.height = 0; + resultsList.setAttribute('aria-hidden', 'true'); + + // Add the results for each of the domain. + var isMonthGroupedResult = this.getRangeInDays() == HistoryModel.Range.MONTH; + for (var j = 0, visit; visit = domainVisits[j]; j++) { + resultsList.appendChild(visit.getResultDOM({ + focusless: true, + useMonthDate: isMonthGroupedResult, + })); + this.setVisitRendered_(visit); + } +}; + +/** + * Enables or disables the time range buttons. + * @private + */ +HistoryView.prototype.updateRangeButtons_ = function() { + // The enabled state for the previous, today and next buttons. + var previousState = false; + var todayState = false; + var nextState = false; + var usePage = (this.getRangeInDays() == HistoryModel.Range.ALL_TIME); + + // Use pagination for most recent visits, offset otherwise. + // TODO(sergiu): Maybe send just one variable in the future. + if (usePage) { + if (this.getPage() != 0) { + nextState = true; + todayState = true; + } + previousState = this.model_.hasMoreResults(); + } else { + if (this.getOffset() != 0) { + nextState = true; + todayState = true; + } + previousState = !this.model_.isQueryFinished_; + } + + $('range-previous').disabled = !previousState; + $('range-today').disabled = !todayState; + $('range-next').disabled = !nextState; +}; + +/** + * Groups visits by domain, sorting them by the number of visits. + * @param {Array} visits Visits received from the query results. + * @param {Element} results Object where the results are added to. + * @private + */ +HistoryView.prototype.groupVisitsByDomain_ = function(visits, results) { + var visitsByDomain = {}; + var domains = []; + + // Group the visits into a dictionary and generate a list of domains. + for (var i = 0, visit; visit = visits[i]; i++) { + var domain = visit.domain_; + if (!visitsByDomain[domain]) { + visitsByDomain[domain] = []; + domains.push(domain); + } + visitsByDomain[domain].push(visit); + } + var sortByVisits = function(a, b) { + return visitsByDomain[b].length - visitsByDomain[a].length; + }; + domains.sort(sortByVisits); + + for (var i = 0; i < domains.length; ++i) { + var domain = domains[i]; + this.getGroupedVisitsDOM_(results, domain, visitsByDomain[domain]); + } +}; + +/** + * Adds the results for a month. + * @param {Array} visits Visits returned by the query. + * @param {Node} parentNode Node to which to add the results to. + * @private + */ +HistoryView.prototype.addMonthResults_ = function(visits, parentNode) { + if (visits.length == 0) + return; + + var monthResults = /** @type {HTMLOListElement} */(parentNode.appendChild( + createElementWithClassName('ol', 'month-results'))); + // Don't add checkboxes if entries can not be edited. + if (!this.model_.editingEntriesAllowed) + monthResults.classList.add('no-checkboxes'); + + this.groupVisitsByDomain_(visits, monthResults); +}; + +/** + * Adds the results for a certain day. This includes a title with the day of + * the results and the results themselves, grouped or not. + * @param {Array} visits Visits returned by the query. + * @param {Node} parentNode Node to which to add the results to. + * @private + */ +HistoryView.prototype.addDayResults_ = function(visits, parentNode) { + if (visits.length == 0) + return; + + var firstVisit = visits[0]; + var day = parentNode.appendChild(createElementWithClassName('h3', 'day')); + day.appendChild(document.createTextNode(firstVisit.dateRelativeDay)); + if (firstVisit.continued) { + day.appendChild(document.createTextNode(' ' + + loadTimeData.getString('cont'))); + } + var dayResults = /** @type {HTMLElement} */(parentNode.appendChild( + createElementWithClassName('ol', 'day-results'))); + + // Don't add checkboxes if entries can not be edited. + if (!this.model_.editingEntriesAllowed) + dayResults.classList.add('no-checkboxes'); + + if (this.model_.getGroupByDomain()) { + this.groupVisitsByDomain_(visits, dayResults); + } else { + var lastTime; + + for (var i = 0, visit; visit = visits[i]; i++) { + // If enough time has passed between visits, indicate a gap in browsing. + var thisTime = visit.date.getTime(); + if (lastTime && lastTime - thisTime > BROWSING_GAP_TIME) + dayResults.appendChild(createElementWithClassName('li', 'gap')); + + // Insert the visit into the DOM. + dayResults.appendChild(visit.getResultDOM({ addTitleFavicon: true })); + this.setVisitRendered_(visit); + + lastTime = thisTime; + } + } +}; + +/** + * Adds the text that shows the current interval, used for week and month + * results. + * @param {Node} resultsFragment The element to which the interval will be + * added to. + * @private + */ +HistoryView.prototype.addTimeframeInterval_ = function(resultsFragment) { + if (this.getRangeInDays() == HistoryModel.Range.ALL_TIME) + return; + + // If this is a time range result add some text that shows what is the + // time range for the results the user is viewing. + var timeFrame = resultsFragment.appendChild( + createElementWithClassName('h2', 'timeframe')); + // TODO(sergiu): Figure the best way to show this for the first day of + // the month. + timeFrame.appendChild(document.createTextNode(loadTimeData.getStringF( + 'historyInterval', + this.model_.queryStartTime, + this.model_.queryEndTime))); +}; + +/** + * Update the page with results. + * @param {boolean} doneLoading Whether the current request is complete. + * @private + */ +HistoryView.prototype.displayResults_ = function(doneLoading) { + // Either show a page of results received for the all time results or all the + // received results for the weekly and monthly view. + var results = this.model_.visits_; + if (this.getRangeInDays() == HistoryModel.Range.ALL_TIME) { + var rangeStart = this.pageIndex_ * RESULTS_PER_PAGE; + var rangeEnd = rangeStart + RESULTS_PER_PAGE; + results = this.model_.getNumberedRange(rangeStart, rangeEnd); + } + var searchText = this.model_.getSearchText(); + var groupByDomain = this.model_.getGroupByDomain(); + + if (searchText) { + var headerText; + if (!doneLoading) { + headerText = loadTimeData.getStringF('searchResultsFor', searchText); + } else if (results.length == 0) { + headerText = loadTimeData.getString('noSearchResults'); + } else { + var resultId = results.length == 1 ? 'searchResult' : 'searchResults'; + headerText = loadTimeData.getStringF('foundSearchResults', + results.length, + loadTimeData.getString(resultId), + searchText); + } + $('results-header').textContent = headerText; + + this.addTimeframeInterval_(this.resultDiv_); + + var searchResults = createElementWithClassName('ol', 'search-results'); + + // Don't add checkboxes if entries can not be edited. + if (!this.model_.editingEntriesAllowed) + searchResults.classList.add('no-checkboxes'); + + if (doneLoading) { + for (var i = 0, visit; visit = results[i]; i++) { + if (!visit.isRendered) { + searchResults.appendChild(visit.getResultDOM({ + isSearchResult: true, + addTitleFavicon: true + })); + this.setVisitRendered_(visit); + } + } + } + this.resultDiv_.appendChild(searchResults); + } else { + var resultsFragment = document.createDocumentFragment(); + + this.addTimeframeInterval_(resultsFragment); + + var noResults = results.length == 0 && doneLoading; + $('results-header').textContent = noResults ? + loadTimeData.getString('noResults') : ''; + + if (noResults) + return; + + if (this.getRangeInDays() == HistoryModel.Range.MONTH && + groupByDomain) { + // Group everything together in the month view. + this.addMonthResults_(results, resultsFragment); + } else { + var dayStart = 0; + var dayEnd = 0; + // Go through all of the visits and process them in chunks of one day. + while (dayEnd < results.length) { + // Skip over the ones that are already rendered. + while (dayStart < results.length && results[dayStart].isRendered) + ++dayStart; + var dayEnd = dayStart + 1; + while (dayEnd < results.length && results[dayEnd].continued) + ++dayEnd; + + this.addDayResults_( + results.slice(dayStart, dayEnd), resultsFragment); + } + } + + // Add all the days and their visits to the page. + this.resultDiv_.appendChild(resultsFragment); + } + // After the results have been added to the DOM, determine the size of the + // time column. + this.setTimeColumnWidth_(); +}; + +var focusGridRowSelector = [ + ':-webkit-any(.day-results, .search-results) > .entry:not(.fade-out)', + '.expand .grouped .entry:not(.fade-out)', + '.site-domain-wrapper' +].join(', '); + +/** @private */ +HistoryView.prototype.updateFocusGrid_ = function() { + var rows = this.resultDiv_.querySelectorAll(focusGridRowSelector); + this.focusGrid_.destroy(); + + for (var i = 0; i < rows.length; ++i) { + assert(rows[i].parentNode); + this.focusGrid_.addRow(new HistoryFocusRow(rows[i], this.resultDiv_)); + } + this.focusGrid_.ensureRowActive(); +}; + +/** + * Update the visibility of the page navigation buttons. + * @private + */ +HistoryView.prototype.updateNavBar_ = function() { + this.updateRangeButtons_(); + + // If grouping by domain is enabled, there's a control bar on top, don't show + // the one on the bottom as well. + if (!loadTimeData.getBoolean('groupByDomain')) { + $('newest-button').hidden = this.pageIndex_ == 0; + $('newer-button').hidden = this.pageIndex_ == 0; + $('older-button').hidden = + this.model_.rangeInDays_ != HistoryModel.Range.ALL_TIME || + !this.model_.hasMoreResults(); + } +}; + +/** + * Updates the visibility of the 'Clear browsing data' button. + * Only used on mobile platforms. + * @private + */ +HistoryView.prototype.updateClearBrowsingDataButton_ = function() { + // Ideally, we should hide the 'Clear browsing data' button whenever the + // soft keyboard is visible. This is not possible, so instead, hide the + // button whenever the search field has focus. + $('clear-browsing-data').hidden = + (document.activeElement === $('search-field')); + + // If clear browsing button is not positioned at the bottom, set display to + // force a redraw. This occurs when switching between screen sizes in iOS + // multi-tasking. + if (!$('clear-browsing-data').hidden && + $('clear-browsing-data').getBoundingClientRect().bottom !== + $('history-page').getBoundingClientRect().height) { + $('clear-browsing-data').style.display = 'block'; + } +}; + +/** + * Dynamically sets the min-width of the time column for history entries. + * This ensures that all entry times will have the same width, without + * imposing a fixed width that may not be appropriate for some locales. + * @private + */ +HistoryView.prototype.setTimeColumnWidth_ = function() { + // Find the maximum width of all the time elements on the page. + var times = this.resultDiv_.querySelectorAll('.entry .time'); + Array.prototype.forEach.call(times, function(el) { + el.style.minWidth = '-webkit-min-content'; + }); + var widths = Array.prototype.map.call(times, function(el) { + // Add an extra pixel to prevent rounding errors from causing the text to + // be ellipsized at certain zoom levels (see crbug.com/329779). + return el.clientWidth + 1; + }); + Array.prototype.forEach.call(times, function(el) { + el.style.minWidth = ''; + }); + var maxWidth = widths.length ? Math.max.apply(null, widths) : 0; + + // Add a dynamic stylesheet to the page (or replace the existing one), to + // ensure that all entry times have the same width. + var styleEl = $('timeColumnStyle'); + if (!styleEl) { + styleEl = document.head.appendChild(document.createElement('style')); + styleEl.id = 'timeColumnStyle'; + } + styleEl.textContent = '.entry .time { min-width: ' + maxWidth + 'px; }'; +}; + +/** + * Toggles an element in the grouped history. + * @param {Event} e The event with element |e.target| which was clicked on. + * @private + */ +HistoryView.prototype.toggleGroupedVisits_ = function(e) { + var entry = findAncestorByClass(/** @type {Element} */(e.target), + 'site-entry'); + var innerResultList = entry.querySelector('.site-results'); + + if (entry.classList.contains('expand')) { + innerResultList.style.height = 0; + innerResultList.setAttribute('aria-hidden', 'true'); + } else { + innerResultList.setAttribute('aria-hidden', 'false'); + innerResultList.style.height = 'auto'; + // -webkit-transition does not work on height:auto elements so first set + // the height to auto so that it is computed and then set it to the + // computed value in pixels so the transition works properly. + var height = innerResultList.clientHeight; + innerResultList.style.height = 0; + setTimeout(function() { + innerResultList.style.height = height + 'px'; + }, 0); + } + + entry.classList.toggle('expand'); + + var root = entry.querySelector('.site-domain-wrapper'); + + this.focusGrid_.rows.forEach(function(row) { + row.makeActive(row.root == root); + }); + + this.updateFocusGrid_(); +}; + +/////////////////////////////////////////////////////////////////////////////// +// State object: +/** + * An 'AJAX-history' implementation. + * @param {HistoryModel} model The model we're representing. + * @param {HistoryView} view The view we're representing. + * @constructor + */ +function PageState(model, view) { + // Enforce a singleton. + if (PageState.instance) { + return PageState.instance; + } + + this.model = model; + this.view = view; + + if (typeof this.checker_ != 'undefined' && this.checker_) { + clearInterval(this.checker_); + } + + // TODO(glen): Replace this with a bound method so we don't need + // public model and view. + this.checker_ = window.setInterval(function() { + var hashData = this.getHashData(); + var page = parseInt(hashData.page, 10); + var range = parseInt(hashData.range, 10); + var offset = parseInt(hashData.offset, 10); + if (hashData.q != this.model.getSearchText() || + page != this.view.getPage() || + range != this.model.rangeInDays || + offset != this.model.offset) { + this.view.setPageState(hashData.q, page, range, offset); + } + }.bind(this), 50); +} + +/** + * Holds the singleton instance. + */ +PageState.instance = null; + +/** + * @return {Object} An object containing parameters from our window hash. + */ +PageState.prototype.getHashData = function() { + var result = { + q: '', + page: 0, + range: 0, + offset: 0 + }; + + if (!window.location.hash) + return result; + + var hashSplit = window.location.hash.substr(1).split('&'); + for (var i = 0; i < hashSplit.length; i++) { + var pair = hashSplit[i].split('='); + if (pair.length > 1) + result[pair[0]] = decodeURIComponent(pair[1].replace(/\+/g, ' ')); + } + + return result; +}; + +/** + * Set the hash to a specified state, this will create an entry in the + * session history so the back button cycles through hash states, which + * are then picked up by our listener. + * @param {string} term The current search string. + * @param {number} page The page currently being viewed. + * @param {HistoryModel.Range} range The range to view or search over. + * @param {number} offset Set the begining of the query to the specific offset. + */ +PageState.prototype.setUIState = function(term, page, range, offset) { + // Make sure the form looks pretty. + $('search-field').value = term; + var hash = this.getHashData(); + if (hash.q != term || hash.page != page || hash.range != range || + hash.offset != offset) { + window.location.hash = PageState.getHashString(term, page, range, offset); + } +}; + +/** + * Static method to get the hash string for a specified state + * @param {string} term The current search string. + * @param {number} page The page currently being viewed. + * @param {HistoryModel.Range} range The range to view or search over. + * @param {number} offset Set the begining of the query to the specific offset. + * @return {string} The string to be used in a hash. + */ +PageState.getHashString = function(term, page, range, offset) { + // Omit elements that are empty. + var newHash = []; + + if (term) + newHash.push('q=' + encodeURIComponent(term)); + + if (page) + newHash.push('page=' + page); + + if (range) + newHash.push('range=' + range); + + if (offset) + newHash.push('offset=' + offset); + + return newHash.join('&'); +}; + +/////////////////////////////////////////////////////////////////////////////// +// Document Functions: +/** + * Window onload handler, sets up the page. + */ +function load() { + FocusOutlineManager.forDocument(document); + + var searchField = $('search-field'); + + historyModel = new HistoryModel(); + historyView = new HistoryView(historyModel); + pageState = new PageState(historyModel, historyView); + + // Create default view. + var hashData = pageState.getHashData(); + var page = parseInt(hashData.page, 10) || historyView.getPage(); + var range = /** @type {HistoryModel.Range} */(parseInt(hashData.range, 10)) || + historyView.getRangeInDays(); + var offset = parseInt(hashData.offset, 10) || historyView.getOffset(); + historyView.setPageState(hashData.q, page, range, offset); + + if ($('overlay')) { + cr.ui.overlay.setupOverlay($('overlay')); + cr.ui.overlay.globalInitialization(); + } + + var doSearch = function(e) { + recordUmaAction('HistoryPage_Search'); + historyView.setSearch(searchField.value); + searchField.blur(); // Dismiss the keyboard. + }; + + var removeMenu = getRequiredElement('remove-visit'); + // Decorate remove-visit before disabling/hiding because the values are + // overwritten when decorating a MenuItem that has a Command. + cr.ui.decorate(removeMenu, MenuItem); + removeMenu.disabled = !loadTimeData.getBoolean('allowDeletingHistory'); + removeMenu.hidden = loadTimeData.getBoolean('hideDeleteVisitUI'); + + document.addEventListener('command', handleCommand); + + searchField.addEventListener('search', doSearch); + $('search-button').addEventListener('click', doSearch); + + $('more-from-site').addEventListener('activate', function(e) { + activeVisit.showMoreFromSite_(); + activeVisit = null; + }); + + // Only show the controls if the command line switch is activated or the user + // is supervised. + if (loadTimeData.getBoolean('groupByDomain')) { + $('history-page').classList.add('big-topbar-page'); + $('filter-controls').hidden = false; + } + // Hide the top container which has the "Clear browsing data" and "Remove + // selected entries" buttons if deleting history is not allowed. + if (!loadTimeData.getBoolean('allowDeletingHistory')) + $('top-container').hidden = true; + + // Adjust the position of the notification bar when the window size changes. + window.addEventListener('resize', + historyView.positionNotificationBar.bind(historyView)); + + // Move the search box out of the header. + var resultsDisplay = $('results-display'); + resultsDisplay.parentNode.insertBefore($('search-field'), resultsDisplay); + + window.addEventListener( + 'resize', historyView.updateClearBrowsingDataButton_); + + // Trigger window resize event when search field is focused to force update + // of the clear browsing button, which should disappear when search field + // is active. The window is not resized when the virtual keyboard is shown + // on iOS. + searchField.addEventListener('focus', function() { + cr.dispatchSimpleEvent(window, 'resize'); + }); + + // When the search field loses focus, add a delay before updating the + // visibility, otherwise the button will flash on the screen before the + // keyboard animates away. + searchField.addEventListener('blur', function() { + setTimeout(historyView.updateClearBrowsingDataButton_, 250); + }); + + // Move the button to the bottom of the page. + $('history-page').appendChild($('clear-browsing-data')); + + function checkKeyboardVisibility() { + // Figure out the real height based on the orientation, becauase + // screen.width and screen.height don't update after rotation. + var screenHeight = window.orientation % 180 ? screen.width : screen.height; + + // Assume that the keyboard is visible if more than 30% of the screen is + // taken up by window chrome. + var isKeyboardVisible = (window.innerHeight / screenHeight) < 0.7; + + document.body.classList.toggle('ios-keyboard-visible', isKeyboardVisible); + } + window.addEventListener('orientationchange', checkKeyboardVisibility); + window.addEventListener('resize', checkKeyboardVisibility); +} + +/** + * Handle all commands in the history page. + * @param {!Event} e is a command event. + */ +function handleCommand(e) { + switch (e.command.id) { + case 'remove-visit-command': + // Removing visited items needs to be done with a command in order to have + // proper focus. This is because the command event is handled after the + // menu dialog is no longer visible and focus has returned to the history + // items. The activate event is handled when the menu dialog is still + // visible and focus is lost. + // removeEntryFromHistory_ will update activeVisit to the newly focused + // history item. + assert(!$('remove-visit').disabled); + activeVisit.removeEntryFromHistory_(e); + break; + } +} + +/** + * Updates the filter status labels of a host/URL entry to the current value. + * @param {Element} statusElement The div which contains the status labels. + * @param {SupervisedUserFilteringBehavior} newStatus The filter status of the + * current domain/URL. + */ +function updateHostStatus(statusElement, newStatus) { + var filteringBehaviorDiv = + statusElement.querySelector('.filtering-behavior'); + // Reset to the base class first, then add modifier classes if needed. + filteringBehaviorDiv.className = 'filtering-behavior'; + if (newStatus == SupervisedUserFilteringBehavior.BLOCK) { + filteringBehaviorDiv.textContent = + loadTimeData.getString('filterBlocked'); + filteringBehaviorDiv.classList.add('filter-blocked'); + } else { + filteringBehaviorDiv.textContent = ''; + } +} + +/** + * Click handler for the 'Clear browsing data' dialog. + * @param {Event} e The click event. + */ +function openClearBrowsingData(e) { + recordUmaAction('HistoryPage_InitClearBrowsingData'); + chrome.send('clearBrowsingData'); +} + +/** + * Shows the dialog for the user to confirm removal of selected history entries. + */ +function showConfirmationOverlay() { + $('alertOverlay').classList.add('showing'); + $('overlay').hidden = false; + $('history-page').setAttribute('aria-hidden', 'true'); + + // Change focus to the overlay if any other control was focused by keyboard + // before. Otherwise, no one should have focus. + var focusOverlay = FocusOutlineManager.forDocument(document).visible && + document.activeElement != document.body; + if ($('history-page').contains(document.activeElement)) + document.activeElement.blur(); + + if (focusOverlay) { + // Wait until the browser knows the button has had a chance to become + // visible. + window.requestAnimationFrame(function() { + var button = cr.ui.overlay.getDefaultButton($('overlay')); + if (button) + button.focus(); + }); + } + $('alertOverlay').classList.toggle('focus-on-hide', focusOverlay); +} + +/** + * Hides the confirmation overlay used to confirm selected history entries. + */ +function hideConfirmationOverlay() { + $('alertOverlay').classList.remove('showing'); + $('overlay').hidden = true; + $('history-page').removeAttribute('aria-hidden'); +} + +/** + * Shows the confirmation alert for history deletions and permits browser tests + * to override the dialog. + * @param {function()=} okCallback A function to be called when the user presses + * the ok button. + * @param {function()=} cancelCallback A function to be called when the user + * presses the cancel button. + */ +function confirmDeletion(okCallback, cancelCallback) { + alertOverlay.setValues( + loadTimeData.getString('removeSelected'), + loadTimeData.getString('deleteWarning'), + loadTimeData.getString('deleteConfirm'), + loadTimeData.getString('cancel'), + okCallback, + cancelCallback); + showConfirmationOverlay(); +} + +/** + * Click handler for the 'Remove selected items' button. + * Confirms the deletion with the user, and then deletes the selected visits. + */ +function removeItems() { + recordUmaAction('HistoryPage_RemoveSelected'); + if (!loadTimeData.getBoolean('allowDeletingHistory')) + return; + + var checked = $('results-display').querySelectorAll( + '.entry-box input[type=checkbox]:checked:not([disabled])'); + var disabledItems = []; + var toBeRemoved = []; + + for (var i = 0; i < checked.length; i++) { + var checkbox = checked[i]; + var entry = findAncestorByClass(checkbox, 'entry'); + toBeRemoved.push(entry.visit); + + // Disable the checkbox and put a strikethrough style on the link, so the + // user can see what will be deleted. + checkbox.disabled = true; + entry.visit.titleLink.classList.add('to-be-removed'); + disabledItems.push(checkbox); + var integerId = parseInt(entry.visit.id_, 10); + // Record the ID of the entry to signify how many entries are above this + // link on the page. + recordUmaHistogram('HistoryPage.RemoveEntryPosition', + UMA_MAX_BUCKET_VALUE, + integerId); + if (integerId <= UMA_MAX_SUBSET_BUCKET_VALUE) { + recordUmaHistogram('HistoryPage.RemoveEntryPositionSubset', + UMA_MAX_SUBSET_BUCKET_VALUE, + integerId); + } + if (entry.parentNode.className == 'search-results') + recordUmaAction('HistoryPage_SearchResultRemove'); + } + + function onConfirmRemove() { + recordUmaAction('HistoryPage_ConfirmRemoveSelected'); + historyModel.removeVisitsFromHistory(toBeRemoved, + historyView.reload.bind(historyView)); + $('overlay').removeEventListener('cancelOverlay', onCancelRemove); + hideConfirmationOverlay(); + if ($('alertOverlay').classList.contains('focus-on-hide') && + FocusOutlineManager.forDocument(document).visible) { + $('search-field').focus(); + } + } + + function onCancelRemove() { + recordUmaAction('HistoryPage_CancelRemoveSelected'); + // Return everything to its previous state. + for (var i = 0; i < disabledItems.length; i++) { + var checkbox = disabledItems[i]; + checkbox.disabled = false; + + var entry = findAncestorByClass(checkbox, 'entry'); + entry.visit.titleLink.classList.remove('to-be-removed'); + } + $('overlay').removeEventListener('cancelOverlay', onCancelRemove); + hideConfirmationOverlay(); + if ($('alertOverlay').classList.contains('focus-on-hide') && + FocusOutlineManager.forDocument(document).visible) { + $('remove-selected').focus(); + } + } + + if (checked.length) { + confirmDeletion(onConfirmRemove, onCancelRemove); + $('overlay').addEventListener('cancelOverlay', onCancelRemove); + } +} + +/** + * Handler for the 'click' event on a checkbox. + * @param {Event} e The click event. + */ +function checkboxClicked(e) { + handleCheckboxStateChange(/** @type {!HTMLInputElement} */(e.currentTarget), + e.shiftKey); +} + +/** + * Post-process of checkbox state change. This handles range selection and + * updates internal state. + * @param {!HTMLInputElement} checkbox Clicked checkbox. + * @param {boolean} shiftKey true if shift key is pressed. + */ +function handleCheckboxStateChange(checkbox, shiftKey) { + updateParentCheckbox(checkbox); + var id = Number(checkbox.id.slice('checkbox-'.length)); + // Handle multi-select if shift was pressed. + if (shiftKey && (selectionAnchor != -1)) { + var checked = checkbox.checked; + // Set all checkboxes from the anchor up to the clicked checkbox to the + // state of the clicked one. + var begin = Math.min(id, selectionAnchor); + var end = Math.max(id, selectionAnchor); + for (var i = begin; i <= end; i++) { + var ithCheckbox = document.querySelector('#checkbox-' + i); + if (ithCheckbox) { + ithCheckbox.checked = checked; + updateParentCheckbox(ithCheckbox); + } + } + } + selectionAnchor = id; + + historyView.updateSelectionEditButtons(); +} + +/** + * Handler for the 'click' event on a domain checkbox. Checkes or unchecks the + * checkboxes of the visits to this domain in the respective group. + * @param {Event} e The click event. + */ +function domainCheckboxClicked(e) { + var siteEntry = findAncestorByClass(/** @type {Element} */(e.currentTarget), + 'site-entry'); + var checkboxes = + siteEntry.querySelectorAll('.site-results input[type=checkbox]'); + for (var i = 0; i < checkboxes.length; i++) + checkboxes[i].checked = e.currentTarget.checked; + historyView.updateSelectionEditButtons(); + // Stop propagation as clicking the checkbox would otherwise trigger the + // group to collapse/expand. + e.stopPropagation(); +} + +/** + * Updates the domain checkbox for this visit checkbox if it has been + * unchecked. + * @param {Element} checkbox The checkbox that has been clicked. + */ +function updateParentCheckbox(checkbox) { + if (checkbox.checked) + return; + + var entry = findAncestorByClass(checkbox, 'site-entry'); + if (!entry) + return; + + var groupCheckbox = entry.querySelector('.site-domain-wrapper input'); + if (groupCheckbox) + groupCheckbox.checked = false; +} + +/** + * Handle click event for entryBoxes. + * @param {!Event} event A click event. + */ +function entryBoxClick(event) { + event = /** @type {!MouseEvent} */(event); + // Do nothing if a bookmark star is clicked. + if (event.defaultPrevented) + return; + var element = event.target; + // Do nothing if the event happened in an interactive element. + for (; element != event.currentTarget; element = element.parentNode) { + switch (element.tagName) { + case 'A': + case 'BUTTON': + case 'INPUT': + return; + } + } + var checkbox = assertInstanceof($(event.currentTarget.getAttribute('for')), + HTMLInputElement); + checkbox.checked = !checkbox.checked; + handleCheckboxStateChange(checkbox, event.shiftKey); + // We don't want to focus on the checkbox. + event.preventDefault(); +} + +/** + * Called when an individual history entry has been removed from the page. + * This will only be called when all the elements affected by the deletion + * have been removed from the DOM and the animations have completed. + */ +function onEntryRemoved() { + historyView.onEntryRemoved(); +} + +/** + * Triggers a fade-out animation, and then removes |node| from the DOM. + * @param {Node} node The node to be removed. + * @param {Function?} onRemove A function to be called after the node + * has been removed from the DOM. + * @param {*=} opt_scope An optional scope object to call |onRemove| with. + */ +function removeNode(node, onRemove, opt_scope) { + node.classList.add('fade-out'); // Trigger CSS fade out animation. + + // Delete the node when the animation is complete. + node.addEventListener('webkitTransitionEnd', function(e) { + node.parentNode.removeChild(node); + + // In case there is nested deletion happening, prevent this event from + // being handled by listeners on ancestor nodes. + e.stopPropagation(); + + if (onRemove) + onRemove.call(opt_scope); + }); +} + +/** + * Builds the DOM elements to show the filtering status of a domain/URL. + * @param {SupervisedUserFilteringBehavior} filteringBehavior The filter + * behavior for this item. + * @return {Element} Returns the DOM elements which show the status. + */ +function getFilteringStatusDOM(filteringBehavior) { + var filterStatusDiv = createElementWithClassName('div', 'filter-status'); + var filteringBehaviorDiv = + createElementWithClassName('div', 'filtering-behavior'); + filterStatusDiv.appendChild(filteringBehaviorDiv); + + updateHostStatus(filterStatusDiv, filteringBehavior); + return filterStatusDiv; +} + + +/////////////////////////////////////////////////////////////////////////////// +// Chrome callbacks: + +/** + * Our history system calls this function with results from searches. + * @param {HistoryQuery} info An object containing information about the query. + * @param {Array<HistoryEntry>} results A list of results. + */ +function historyResult(info, results) { + historyModel.addResults(info, results); +} + +/** + * Called by the history backend when history removal is successful. + */ +function deleteComplete() { + historyModel.deleteComplete(); +} + +/** + * Called by the history backend when history removal is unsuccessful. + */ +function deleteFailed() { + window.console.log('Delete failed'); +} + +/** + * Called when the history is deleted by someone else. + */ +function historyDeleted() { + var anyChecked = document.querySelector('.entry input:checked') != null; + // Reload the page, unless the user has any items checked. + // TODO(dubroy): We should just reload the page & restore the checked items. + if (!anyChecked) + historyView.reload(); +} + +// Add handlers to HTML elements. +document.addEventListener('DOMContentLoaded', load); + +// This event lets us enable and disable menu items before the menu is shown. +document.addEventListener('canExecute', function(e) { + e.canExecute = true; +}); diff --git a/ios/chrome/app/resources/history/history_mobile.css b/ios/chrome/app/resources/history/history_mobile.css new file mode 100644 index 0000000..3b6f9a3 --- /dev/null +++ b/ios/chrome/app/resources/history/history_mobile.css @@ -0,0 +1,330 @@ +/* Copyright (c) 2013 The Chromium Authors. All rights reserved. + * Use of this source code is governed by a BSD-style license that can be + * found in the LICENSE file. */ + +/* This file contains styles specific to iOS. */ + +html:not(.focus-outline-visible) :focus { + outline: none; +} + +html { + height: 100%; +} + +body { + color: rgb(76, 76, 76); + font-size: initial; + height: 100%; + margin: 0; +} + +.page { + -webkit-flex-flow: column; + display: -webkit-flex; + height: 100%; +} + +#scrolling-container { + -webkit-flex: auto; /* Container should take up extra vertical space. */ + -webkit-overflow-scrolling: touch; + overflow-y: auto; +} + +h1 { + font-weight: bold; + margin-bottom: 12px; +} + +#top-container, +#results-display { + margin: 0; +} + +#top-container, +#results-display, +#results-pagination { + max-width: none; +} + +h1, +h3, +#notification-bar, +#search-field, +.entry-box, +#loading-spinner { + padding-left: 16px; + padding-right: 16px; +} + +h3 { + background: rgb(245, 245, 245); + color: rgb(138, 138, 138); + font-size: 14px; + height: 30px; + line-height: 30px; + margin-top: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#search-field { + -webkit-padding-start: 64px; + background-image: + url(../../../../../ui/webui/resources/images/2x/search.png); + background-position: 16px center; + background-repeat: no-repeat; + background-size: 32px; + border: 0; + display: block; + line-height: 1.5; + margin-top: 16px; + width: 100%; +} + +html[dir='rtl'] #search-field { + background-position: right 16px center; +} + +#notification-bar.alone { + float: none; + font-size: 75%; + margin: 0; + padding-bottom: 0; + padding-top: 0; +} + +#remove-selected, +#search-button, +.gap { + display: none; +} + +.entry-box { + -webkit-padding-end: 0; +} + +button.remove-entry { + background: url(../../../../../ui/webui/resources/images/2x/x-thin.png) + no-repeat center center; + background-size: 13px; + border: 0; + box-sizing: border-box; + height: 100%; + min-width: 45px; + opacity: 0.7; + padding: 0 16px; + vertical-align: top; + width: 45px; +} + +button.remove-entry:active { + opacity: 1.0; +} + +.entry-box { + margin-bottom: 0; + margin-top: 0; + padding-bottom: 0; +} + +h3, +.entry, +#search-field { + border-bottom: 1px solid rgb(220, 220, 220); + border-top: 1px solid rgb(220, 220, 220); + margin-bottom: -1px; + overflow: hidden; +} + +.entry-box, +#search-field, +#results-pagination button { + height: 60px; +} + +.entry-box-container { + display: block; +} + +input { + border-radius: 0; +} + +#clear-browsing-data { + /* Style it like a native Android button. */ + background-color: rgb(221, 221, 221); + border: 0; + border-radius: 0; + border-top: 1px solid rgb(198, 198, 198); + box-shadow: none; + font-size: 75%; + font-weight: bold; + height: 46px; + margin: 0; + min-height: 46px; + text-shadow: none; + text-transform: uppercase; + width: 100%; +} + +.day-results, +.search-results { + margin: 0; +} + +/* Fade out the entry-box, rather than its parent node, so that the dividing + line between entries doesn't fade out. */ +.entry.fade-out .entry-box { + -webkit-transition: opacity 200ms; + opacity: 1; +} + +.entry.fade-out { + opacity: 1; +} + +.entry.fade-out .entry-box { + opacity: 0; +} + +.entry input[type=checkbox] { + display: none; +} + +.entry .visit-entry { + -webkit-flex: auto; + -webkit-flex-flow: column; + -webkit-padding-start: 48px; + background-size: 32px; + line-height: 1.3; +} + +.entry .visit-entry.blocked-indicator { + line-height: 2; +} + +.entry .visit-entry :-webkit-any(a, .domain) { + display: block; + margin-left: 0; + margin-right: 0; + min-width: 0; + overflow: hidden; + padding-left: 0; + padding-right: 0; + text-overflow: ellipsis; + white-space: nowrap; +} + +.entry .visit-entry.blocked-indicator a { + display: inline; +} + +.entry .domain { + font-size: 14px; +} + +#older-button { + -webkit-padding-end: 16px; +} + +#newest-button { + -webkit-padding-start: 16px; +} + +#loading-spinner { + margin-top: 16px; +} + +/* iOS does not support the latest flexbox syntax, only the 2009 working draft + syntax (http://www.w3.org/TR/2009/WD-css3-flexbox-20090723/). */ +.entry-box, +.site-domain-wrapper { + -wekbit-box-align: center; + display: -webkit-box; +} + +.entry .visit-entry { + -webkit-box-flex: 1; + -webkit-box-orient: vertical; + -webkit-box-pack: center; + display: -webkit-box; +} + +#scrolling-container { + bottom: 46px; + left: 0; + position: fixed; + right: 0; + top: 0; +} + +#clear-browsing-data { + bottom: 0; + position: fixed; +} + +.entry .bookmark-section { + display: none; +} + +.entry .time { + line-height: 60px; + min-width: 90px; + text-align: inherit; + width: 90px; +} + +@media only screen and (max-width:600px) { + + /* Omit the time on very small screens. */ + .entry .time { + display: none; + } + +} /* @media only screen and (max-width:600px) */ + +@media only screen and (min-width:720px) { + + h3, + .entry, + #search-field { + border: 1px solid rgb(220, 220, 220); + } + + h3 { + margin-top: 30px; + } + + #scrolling-container { + padding-bottom: 30px; + } + + #scrolling-container > * { + margin-left: auto; + margin-right: auto; + max-width: 718px; + } + + h1, + #notification-bar, + #loading-spinner { + padding-left: 0; + padding-right: 0; + } + +} /* @media only screen and (max-width:720px) */ + +.ios-keyboard-visible #clear-browsing-data { + display: none; +} + +.ios-keyboard-visible #scrolling-container { + /* Should be 0, but that breaks scrolling -- see crbug.com/292715. */ + bottom: -1px; +} + +#results-header:empty { + display: none; +} diff --git a/ios/chrome/app/resources/history/other_devices.js b/ios/chrome/app/resources/history/other_devices.js new file mode 100644 index 0000000..8f46192 --- /dev/null +++ b/ios/chrome/app/resources/history/other_devices.js @@ -0,0 +1,573 @@ +// Copyright (c) 2013 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/** + * @fileoverview The section of the history page that shows tabs from sessions + on other devices. + */ + +/////////////////////////////////////////////////////////////////////////////// +// Globals: +/** @const */ var MAX_NUM_COLUMNS = 3; +/** @const */ var NB_ENTRIES_FIRST_ROW_COLUMN = 6; +/** @const */ var NB_ENTRIES_OTHER_ROWS_COLUMN = 0; + +// Histogram buckets for UMA tracking of menu usage. +/** @const */ var HISTOGRAM_EVENT = { + INITIALIZED: 0, + SHOW_MENU: 1, + LINK_CLICKED: 2, + LINK_RIGHT_CLICKED: 3, + SESSION_NAME_RIGHT_CLICKED: 4, + SHOW_SESSION_MENU: 5, + COLLAPSE_SESSION: 6, + EXPAND_SESSION: 7, + OPEN_ALL: 8, + HAS_FOREIGN_DATA: 9, + LIMIT: 10 // Should always be the last one. +}; + +/** + * Record an event in the UMA histogram. + * @param {number} eventId The id of the event to be recorded. + * @private + */ +function recordUmaEvent_(eventId) { + chrome.send('metricsHandler:recordInHistogram', + ['HistoryPage.OtherDevicesMenu', eventId, HISTOGRAM_EVENT.LIMIT]); +} + +/////////////////////////////////////////////////////////////////////////////// +// DeviceContextMenuController: + +/** + * Controller for the context menu for device names in the list of sessions. + * @constructor + */ +function DeviceContextMenuController() { + this.__proto__ = DeviceContextMenuController.prototype; + this.initialize(); +} +cr.addSingletonGetter(DeviceContextMenuController); + +// DeviceContextMenuController, Public: --------------------------------------- + +/** + * Initialize the context menu for device names in the list of sessions. + */ +DeviceContextMenuController.prototype.initialize = function() { + var menu = new cr.ui.Menu; + cr.ui.decorate(menu, cr.ui.Menu); + this.menu = menu; + this.collapseItem_ = this.appendMenuItem_('collapseSessionMenuItemText'); + this.collapseItem_.addEventListener('activate', + this.onCollapseOrExpand_.bind(this)); + this.expandItem_ = this.appendMenuItem_('expandSessionMenuItemText'); + this.expandItem_.addEventListener('activate', + this.onCollapseOrExpand_.bind(this)); + this.openAllItem_ = this.appendMenuItem_('restoreSessionMenuItemText'); + this.openAllItem_.addEventListener('activate', + this.onOpenAll_.bind(this)); +}; + +/** + * Set the session data for the session the context menu was invoked on. + * This should never be called when the menu is visible. + * @param {Object} session The model object for the session. + */ +DeviceContextMenuController.prototype.setSession = function(session) { + this.session_ = session; + this.updateMenuItems_(); +}; + +// DeviceContextMenuController, Private: -------------------------------------- + +/** + * Appends a menu item to |this.menu|. + * @param {string} textId The ID for the localized string that acts as + * the item's label. + * @return {Element} The button used for a given menu option. + * @private + */ +DeviceContextMenuController.prototype.appendMenuItem_ = function(textId) { + var button = document.createElement('button'); + this.menu.appendChild(button); + cr.ui.decorate(button, cr.ui.MenuItem); + button.textContent = loadTimeData.getString(textId); + return button; +}; + +/** + * Handler for the 'Collapse' and 'Expand' menu items. + * @param {Event} e The activation event. + * @private + */ +DeviceContextMenuController.prototype.onCollapseOrExpand_ = function(e) { + this.session_.collapsed = !this.session_.collapsed; + this.updateMenuItems_(); + chrome.send('setForeignSessionCollapsed', + [this.session_.tag, this.session_.collapsed]); + chrome.send('getForeignSessions'); // Refresh the list. + + var eventId = this.session_.collapsed ? + HISTOGRAM_EVENT.COLLAPSE_SESSION : HISTOGRAM_EVENT.EXPAND_SESSION; + recordUmaEvent_(eventId); +}; + +/** + * Handler for the 'Open all' menu item. + * @param {Event} e The activation event. + * @private + */ +DeviceContextMenuController.prototype.onOpenAll_ = function(e) { + chrome.send('openForeignSession', [this.session_.tag]); + recordUmaEvent_(HISTOGRAM_EVENT.OPEN_ALL); +}; + +/** + * Set the visibility of the Expand/Collapse menu items based on the state + * of the session that this menu is currently associated with. + * @private + */ +DeviceContextMenuController.prototype.updateMenuItems_ = function() { + this.collapseItem_.hidden = this.session_.collapsed; + this.expandItem_.hidden = !this.session_.collapsed; + this.menu.selectedItem = this.menu.querySelector(':not([hidden])'); +}; + + +/////////////////////////////////////////////////////////////////////////////// +// Device: + +/** + * Class to hold all the information about a device entry and generate a DOM + * node for it. + * @param {Object} session An object containing the device's session data. + * @param {DevicesView} view The view object this entry belongs to. + * @constructor + */ +function Device(session, view) { + this.view_ = view; + this.session_ = session; + this.searchText_ = view.getSearchText(); +} + +// Device, Public: ------------------------------------------------------------ + +/** + * Get the DOM node to display this device. + * @param {int} maxNumTabs The maximum number of tabs to display. + * @param {int} row The row in which this device is displayed. + * @return {Object} A DOM node to draw the device. + */ +Device.prototype.getDOMNode = function(maxNumTabs, row) { + var deviceDiv = createElementWithClassName('div', 'device'); + this.row_ = row; + if (!this.session_) + return deviceDiv; + + // Name heading + var heading = document.createElement('h3'); + var name = heading.appendChild( + createElementWithClassName('span', 'device-name')); + name.textContent = this.session_.name; + heading.sessionData_ = this.session_; + deviceDiv.appendChild(heading); + + // Keep track of the drop down that triggered the menu, so we know + // which element to apply the command to. + var session = this.session_; + function handleDropDownFocus(e) { + DeviceContextMenuController.getInstance().setSession(session); + } + heading.addEventListener('contextmenu', handleDropDownFocus); + + var dropDownButton = new cr.ui.ContextMenuButton; + dropDownButton.tabIndex = 0; + dropDownButton.classList.add('drop-down'); + dropDownButton.title = loadTimeData.getString('actionMenuDescription'); + dropDownButton.addEventListener('mousedown', function(event) { + handleDropDownFocus(event); + // Mousedown handling of cr.ui.MenuButton.handleEvent calls + // preventDefault, which prevents blur of the focused element. We need to + // do blur manually. + document.activeElement.blur(); + }); + dropDownButton.addEventListener('focus', handleDropDownFocus); + heading.appendChild(dropDownButton); + + var timeSpan = createElementWithClassName('div', 'device-timestamp'); + timeSpan.textContent = this.session_.modifiedTime; + deviceDiv.appendChild(timeSpan); + + cr.ui.contextMenuHandler.setContextMenu( + heading, DeviceContextMenuController.getInstance().menu); + if (!this.session_.collapsed) + deviceDiv.appendChild(this.createSessionContents_(maxNumTabs)); + + return deviceDiv; +}; + +/** + * Marks tabs as hidden or not in our session based on the given searchText. + * @param {string} searchText The search text used to filter the content. + */ +Device.prototype.setSearchText = function(searchText) { + this.searchText_ = searchText.toLowerCase(); + for (var i = 0; i < this.session_.windows.length; i++) { + var win = this.session_.windows[i]; + var foundMatch = false; + for (var j = 0; j < win.tabs.length; j++) { + var tab = win.tabs[j]; + if (tab.title.toLowerCase().indexOf(this.searchText_) != -1) { + foundMatch = true; + tab.hidden = false; + } else { + tab.hidden = true; + } + } + win.hidden = !foundMatch; + } +}; + +// Device, Private ------------------------------------------------------------ + +/** + * Create the DOM tree representing the tabs and windows of this device. + * @param {int} maxNumTabs The maximum number of tabs to display. + * @return {Element} A single div containing the list of tabs & windows. + * @private + */ +Device.prototype.createSessionContents_ = function(maxNumTabs) { + var contents = createElementWithClassName('ol', 'device-contents'); + + var sessionTag = this.session_.tag; + var numTabsShown = 0; + var numTabsHidden = 0; + for (var i = 0; i < this.session_.windows.length; i++) { + var win = this.session_.windows[i]; + if (win.hidden) + continue; + + // Show a separator between multiple windows in the same session. + if (i > 0 && numTabsShown < maxNumTabs) + contents.appendChild(document.createElement('hr')); + + for (var j = 0; j < win.tabs.length; j++) { + var tab = win.tabs[j]; + if (tab.hidden) + continue; + + if (numTabsShown < maxNumTabs) { + numTabsShown++; + var a = createElementWithClassName('a', 'device-tab-entry'); + a.href = tab.url; + a.style.backgroundImage = getFaviconImageSet(tab.url); + this.addHighlightedText_(a, tab.title); + // Add a tooltip, since it might be ellipsized. The ones that are not + // necessary will be removed once added to the document, so we can + // compute sizes. + a.title = tab.title; + + // We need to use this to not lose the ids as we go through other loop + // turns. + function makeClickHandler(sessionTag, windowId, tabId) { + return function(e) { + recordUmaEvent_(HISTOGRAM_EVENT.LINK_CLICKED); + chrome.send('openForeignSession', [sessionTag, windowId, tabId, + e.button, e.altKey, e.ctrlKey, e.metaKey, e.shiftKey]); + e.preventDefault(); + }; + }; + a.addEventListener('click', makeClickHandler(sessionTag, + String(win.sessionId), + String(tab.sessionId))); + var wrapper = createElementWithClassName('div', 'device-tab-wrapper'); + wrapper.appendChild(a); + contents.appendChild(wrapper); + } else { + numTabsHidden++; + } + } + } + + if (numTabsHidden > 0) { + var moreLink = document.createElement('a', 'action-link'); + moreLink.classList.add('device-show-more-tabs'); + moreLink.addEventListener('click', this.view_.increaseRowHeight.bind( + this.view_, this.row_, numTabsHidden)); + moreLink.textContent = loadTimeData.getStringF('xMore', numTabsHidden); + var moreWrapper = createElementWithClassName('div', 'more-wrapper'); + moreWrapper.appendChild(moreLink); + contents.appendChild(moreWrapper); + } + + return contents; +}; + +/** + * Add child text nodes to a node such that occurrences of this.searchText_ are + * highlighted. + * @param {Node} node The node under which new text nodes will be made as + * children. + * @param {string} content Text to be added beneath |node| as one or more + * text nodes. + * @private + */ +Device.prototype.addHighlightedText_ = function(node, content) { + var endOfPreviousMatch = 0; + if (this.searchText_) { + var lowerContent = content.toLowerCase(); + var searchTextLenght = this.searchText_.length; + var newMatch = lowerContent.indexOf(this.searchText_, 0); + while (newMatch != -1) { + if (newMatch > endOfPreviousMatch) { + node.appendChild(document.createTextNode( + content.slice(endOfPreviousMatch, newMatch))); + } + endOfPreviousMatch = newMatch + searchTextLenght; + // Mark the highlighted text in bold. + var b = document.createElement('b'); + b.textContent = content.substring(newMatch, endOfPreviousMatch); + node.appendChild(b); + newMatch = lowerContent.indexOf(this.searchText_, endOfPreviousMatch); + } + } + if (endOfPreviousMatch < content.length) { + node.appendChild(document.createTextNode( + content.slice(endOfPreviousMatch))); + } +}; + +/////////////////////////////////////////////////////////////////////////////// +// DevicesView: + +/** + * Functions and state for populating the page with HTML. + * @constructor + */ +function DevicesView() { + this.devices_ = []; // List of individual devices. + this.resultDiv_ = $('other-devices'); + this.searchText_ = ''; + this.rowHeights_ = [NB_ENTRIES_FIRST_ROW_COLUMN]; + this.focusGrids_ = []; + this.updateSignInState(loadTimeData.getBoolean('isUserSignedIn')); + this.hasSeenForeignData_ = false; + recordUmaEvent_(HISTOGRAM_EVENT.INITIALIZED); +} + +// DevicesView, public: ------------------------------------------------------- + +/** + * Updates our sign in state by clearing the view is not signed in or sending + * a request to get the data to display otherwise. + * @param {boolean} signedIn Whether the user is signed in or not. + */ +DevicesView.prototype.updateSignInState = function(signedIn) { + if (signedIn) + chrome.send('getForeignSessions'); + else + this.clearDOM(); +}; + +/** + * Resets the view sessions. + * @param {Object} sessionList The sessions to add. + */ +DevicesView.prototype.setSessionList = function(sessionList) { + this.devices_ = []; + for (var i = 0; i < sessionList.length; i++) + this.devices_.push(new Device(sessionList[i], this)); + this.displayResults_(); + + // This metric should only be emitted if we see foreign data, and it should + // only be emitted once per page refresh. Flip flag to remember because this + // method is called upon any update. + if (!this.hasSeenForeignData_ && sessionList.length > 0) { + this.hasSeenForeignData_ = true; + recordUmaEvent_(HISTOGRAM_EVENT.HAS_FOREIGN_DATA); + } +}; + + +/** + * Sets the current search text. + * @param {string} searchText The text to search. + */ +DevicesView.prototype.setSearchText = function(searchText) { + if (this.searchText_ != searchText) { + this.searchText_ = searchText; + for (var i = 0; i < this.devices_.length; i++) + this.devices_[i].setSearchText(searchText); + this.displayResults_(); + } +}; + +/** + * @return {string} The current search text. + */ +DevicesView.prototype.getSearchText = function() { + return this.searchText_; +}; + +/** + * Clears the DOM content of the view. + */ +DevicesView.prototype.clearDOM = function() { + while (this.resultDiv_.hasChildNodes()) { + this.resultDiv_.removeChild(this.resultDiv_.lastChild); + } +}; + +/** + * Increase the height of a row by the given amount. + * @param {int} row The row number. + * @param {int} height The extra height to add to the givent row. + */ +DevicesView.prototype.increaseRowHeight = function(row, height) { + for (var i = this.rowHeights_.length; i <= row; i++) + this.rowHeights_.push(NB_ENTRIES_OTHER_ROWS_COLUMN); + this.rowHeights_[row] += height; + this.displayResults_(); +}; + +// DevicesView, Private ------------------------------------------------------- + +/** + * @param {!Element} root + * @param {?Node} boundary + * @constructor + * @extends {cr.ui.FocusRow} + */ +function DevicesViewFocusRow(root, boundary) { + cr.ui.FocusRow.call(this, root, boundary); + assert(this.addItem('menu-button', 'button.drop-down') || + this.addItem('device-tab', '.device-tab-entry') || + this.addItem('more-tabs', '.device-show-more-tabs')); +} + +DevicesViewFocusRow.prototype = {__proto__: cr.ui.FocusRow.prototype}; + +/** + * Update the page with results. + * @private + */ +DevicesView.prototype.displayResults_ = function() { + this.clearDOM(); + var resultsFragment = document.createDocumentFragment(); + if (this.devices_.length == 0) + return; + + // We'll increase to 0 as we create the first row. + var rowIndex = -1; + // We need to access the last row and device when we get out of the loop. + var currentRowElement; + // This is only set when changing rows, yet used on all device columns. + var maxNumTabs; + for (var i = 0; i < this.devices_.length; i++) { + var device = this.devices_[i]; + // Should we start a new row? + if (i % MAX_NUM_COLUMNS == 0) { + if (currentRowElement) + resultsFragment.appendChild(currentRowElement); + currentRowElement = createElementWithClassName('div', 'device-row'); + rowIndex++; + if (rowIndex < this.rowHeights_.length) + maxNumTabs = this.rowHeights_[rowIndex]; + else + maxNumTabs = 0; + } + + currentRowElement.appendChild(device.getDOMNode(maxNumTabs, rowIndex)); + } + if (currentRowElement) + resultsFragment.appendChild(currentRowElement); + + this.resultDiv_.appendChild(resultsFragment); + // Remove the tootltip on all lines that don't need it. It's easier to + // remove them here, after adding them all above, since we have the data + // handy above, but we don't have the width yet. Whereas here, we have the + // width, and the nodeValue could contain sub nodes for highlighting, which + // makes it harder to extract the text data here. + tabs = document.getElementsByClassName('device-tab-entry'); + for (var i = 0; i < tabs.length; i++) { + if (tabs[i].scrollWidth <= tabs[i].clientWidth) + tabs[i].title = ''; + } + + this.resultDiv_.appendChild( + createElementWithClassName('div', 'other-devices-bottom')); + + this.focusGrids_.forEach(function(grid) { grid.destroy(); }); + this.focusGrids_.length = 0; + + var devices = this.resultDiv_.querySelectorAll('.device-contents'); + for (var i = 0; i < devices.length; ++i) { + var rows = devices[i].querySelectorAll( + 'h3, .device-tab-wrapper, .more-wrapper'); + if (!rows.length) + continue; + + var grid = new cr.ui.FocusGrid(); + for (var j = 0; j < rows.length; ++j) { + grid.addRow(new DevicesViewFocusRow(rows[j], devices[i])); + } + grid.ensureRowActive(); + this.focusGrids_.push(grid); + } +}; + +/** + * Sets the menu model data. An empty list means that either there are no + * foreign sessions, or tab sync is disabled for this profile. + * |isTabSyncEnabled| makes it possible to distinguish between the cases. + * + * @param {Array} sessionList Array of objects describing the sessions + * from other devices. + * @param {boolean} isTabSyncEnabled Is tab sync enabled for this profile? + */ +function setForeignSessions(sessionList, isTabSyncEnabled) { + // The other devices is shown iff tab sync is enabled. + if (isTabSyncEnabled) + devicesView.setSessionList(sessionList); + else + devicesView.clearDOM(); +} + +/** + * Called when initialized or the user's signed in state changes, + * @param {boolean} isUserSignedIn Is the user currently signed in? + */ +function updateSignInState(isUserSignedIn) { + if (devicesView) + devicesView.updateSignInState(isUserSignedIn); +} + +/////////////////////////////////////////////////////////////////////////////// +// Document Functions: +/** + * Window onload handler, sets up the other devices view. + */ +function load() { + if (!loadTimeData.getBoolean('isInstantExtendedApiEnabled')) + return; + + devicesView = new DevicesView(); + + // Create the context menu that appears when the user right clicks + // on a device name or hit click on the button besides the device name + document.body.appendChild(DeviceContextMenuController.getInstance().menu); + + var doSearch = function(e) { + devicesView.setSearchText($('search-field').value); + }; + $('search-field').addEventListener('search', doSearch); + $('search-button').addEventListener('click', doSearch); + + chrome.send('otherDevicesInitialized'); +} + +// Add handlers to HTML elements. +document.addEventListener('DOMContentLoaded', load); diff --git a/ios/chrome/app/resources/ios_resources.grd b/ios/chrome/app/resources/ios_resources.grd index 71c071a..b52f9c9 100644 --- a/ios/chrome/app/resources/ios_resources.grd +++ b/ios/chrome/app/resources/ios_resources.grd @@ -14,6 +14,9 @@ <structure name="IDR_IOS_INCOGNITO_TAB_HTML" file="ntp/incognito_tab.html" flattenhtml="true" type="chrome_html" /> </structures> <includes> + <include name="IDR_IOS_HISTORY_HTML" file="history/history.html" flattenhtml="true" allowexternalscript="true" type="BINDATA" /> + <include name="IDR_IOS_HISTORY_JS" file="history/history.js" flattenhtml="true" type="BINDATA" /> + <include name="IDR_IOS_HISTORY_OTHER_DEVICES_JS" file="history/other_devices.js" flattenhtml="true" type="BINDATA" /> <include name="IDR_IOS_OMAHA_HTML" file="omaha/omaha.html" flattenhtml="true" allowexternalscript="true" type="BINDATA" /> <include name="IDR_IOS_OMAHA_JS" file="omaha/omaha.js" type="BINDATA" /> </includes> |