Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(376)

Side by Side Diff: chrome/browser/resources/options/intents_list.js

Issue 7624012: First pass on intents options UI. (Closed) Base URL: http://git.chromium.org/git/chromium.git@trunk
Patch Set: Get rid of message-passing changes. Created 9 years, 4 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch | Annotate | Revision Log
OLDNEW
(Empty)
1 // Copyright (c) 2011 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
4
5 cr.define('options', function() {
James Hawkins 2011/08/17 21:34:15 OK, please add a TODO to refactor this with the co
Greg Billock 2011/08/17 22:44:02 I agree that this needs to be done. I put in the n
6 const DeletableItemList = options.DeletableItemList;
7 const DeletableItem = options.DeletableItem;
8 const ArrayDataModel = cr.ui.ArrayDataModel;
9 const ListSingleSelectionModel = cr.ui.ListSingleSelectionModel;
10 const localStrings = new LocalStrings();
11
12 /**
13 * Returns the item's height, like offsetHeight but such that it works better
14 * when the page is zoomed. See the similar calculation in @{code cr.ui.List}.
15 * This version also accounts for the animation done in this file.
16 * @param {Element} item The item to get the height of.
17 * @return {number} The height of the item, calculated with zooming in mind.
18 */
19 function getItemHeight(item) {
20 var height = item.style.height;
21 // Use the fixed animation target height if set, in case the element is
22 // currently being animated and we'd get an intermediate height below.
23 if (height && height.substr(-2) == 'px')
24 return parseInt(height.substr(0, height.length - 2));
25 return item.getBoundingClientRect().height;
26 }
27
28 // Map of parent pathIDs to node objects.
29 var parentLookup = {};
30
31 // Pending requests for child information.
32 var lookupRequests = {};
33
34 /**
35 * Creates a new list item for intent service data. Note that these are
36 * created and destroyed lazily as they scroll into and out of view,
37 * so they must be stateless. We cache the expanded item in
38 * @{code IntentsList} though, so it can keep state.
39 * (Mostly just which item is selected.)
40 *
41 * @param {Object} origin Data used to create an intents list item.
42 * @param {IntentsList} list The list that will contain this item.
43 * @constructor
44 * @extends {DeletableItem}
45 */
46 function IntentsListItem(origin, list) {
47 var listItem = new DeletableItem(null);
48 listItem.__proto__ = IntentsListItem.prototype;
49
50 listItem.origin = origin;
51 listItem.list = list;
52 listItem.decorate();
53
54 // This hooks up updateOrigin() to the list item, makes the top-level
55 // tree nodes (i.e., origins) register their IDs in parentLookup, and
56 // causes them to request their children if they have none. Note that we
57 // have special logic in the setter for the parent property to make sure
58 // that we can still garbage collect list items when they scroll out of
59 // view, even though it appears that we keep a direct reference.
60 if (origin) {
61 origin.parent = listItem;
62 origin.updateOrigin();
63 }
64
65 return listItem;
66 }
67
68 IntentsListItem.prototype = {
69 __proto__: DeletableItem.prototype,
70
71 /** @inheritDoc */
72 decorate: function() {
73 this.siteChild = this.ownerDocument.createElement('div');
74 this.siteChild.className = 'intents-site';
75 this.dataChild = this.ownerDocument.createElement('div');
76 this.dataChild.className = 'intents-data';
77 this.itemsChild = this.ownerDocument.createElement('div');
78 this.itemsChild.className = 'intents-items';
79 this.infoChild = this.ownerDocument.createElement('div');
80 this.infoChild.className = 'intents-details';
81 this.infoChild.hidden = true;
82 var remove = this.ownerDocument.createElement('button');
83 remove.textContent = localStrings.getString('remove_intent');
84 remove.onclick = this.removeIntent_.bind(this);
85 this.infoChild.appendChild(remove);
86 var content = this.contentElement;
87 content.appendChild(this.siteChild);
88 content.appendChild(this.dataChild);
89 content.appendChild(this.itemsChild);
90 this.itemsChild.appendChild(this.infoChild);
91 if (this.origin && this.origin.data) {
92 this.siteChild.textContent = this.origin.data.site;
93 this.siteChild.setAttribute('title', this.origin.data.site);
94 }
95 this.itemList_ = [];
96 },
97
98 /** @type {boolean} */
99 get expanded() {
100 return this.expanded_;
101 },
102 set expanded(expanded) {
103 if (this.expanded_ == expanded)
104 return;
105 this.expanded_ = expanded;
106 if (expanded) {
107 var oldExpanded = this.list.expandedItem;
108 this.list.expandedItem = this;
109 this.updateItems_();
110 if (oldExpanded)
111 oldExpanded.expanded = false;
112 this.classList.add('show-items');
113 this.dataChild.hidden = true;
114 } else {
115 if (this.list.expandedItem == this) {
116 this.list.leadItemHeight = 0;
117 this.list.expandedItem = null;
118 }
119 this.style.height = '';
120 this.itemsChild.style.height = '';
121 this.classList.remove('show-items');
122 this.dataChild.hidden = false;
123 }
124 },
125
126 /**
127 * The callback for the "remove" button shown when an item is selected.
128 * Requests that the currently selected intent service be removed.
129 * @private
130 */
131 removeIntent_: function() {
132 if (this.selectedIndex_ >= 0) {
133 var item = this.itemList_[this.selectedIndex_];
134 if (item && item.node)
135 chrome.send('removeIntent', [item.node.pathId]);
136 }
137 },
138
139 /**
140 * Disable animation within this intents list item, in preparation for
141 * making changes that will need to be animated. Makes it possible to
142 * measure the contents without displaying them, to set animation targets.
143 * @private
144 */
145 disableAnimation_: function() {
146 this.itemsHeight_ = getItemHeight(this.itemsChild);
147 this.classList.add('measure-items');
148 },
149
150 /**
151 * Enable animation after changing the contents of this intents list item.
152 * See @{code disableAnimation_}.
153 * @private
154 */
155 enableAnimation_: function() {
156 if (!this.classList.contains('measure-items'))
157 this.disableAnimation_();
158 this.itemsChild.style.height = '';
159 // This will force relayout in order to calculate the new heights.
160 var itemsHeight = getItemHeight(this.itemsChild);
161 var fixedHeight = getItemHeight(this) + itemsHeight - this.itemsHeight_;
162 this.itemsChild.style.height = this.itemsHeight_ + 'px';
163 // Force relayout before enabling animation, so that if we have
164 // changed things since the last layout, they will not be animated
165 // during subsequent layouts.
166 this.itemsChild.offsetHeight;
167 this.classList.remove('measure-items');
168 this.itemsChild.style.height = itemsHeight + 'px';
169 this.style.height = fixedHeight + 'px';
170 if (this.expanded)
171 this.list.leadItemHeight = fixedHeight;
172 },
173
174 /**
175 * Updates the origin summary to reflect changes in its items.
176 * Both IntentsListItem and IntentsTreeNode implement this API.
177 * This implementation scans the descendants to update the text.
178 */
179 updateOrigin: function() {
180 console.log('IntentsListItem.updateOrigin');
181 var text = '';
182 for (var i = 0; i < this.origin.children.length; ++i) {
183 if (text.length > 0)
184 text += ', ' + this.origin.children[i].data.action;
185 else
186 text = this.origin.children[i].data.action;
187 }
188 this.dataChild.textContent = text;
189
190 if (this.expanded)
191 this.updateItems_();
192 },
193
194 /**
195 * Updates the items section to reflect changes, animating to the new state.
196 * Removes existing contents and calls @{code IntentsTreeNode.createItems}.
197 * @private
198 */
199 updateItems_: function() {
200 this.disableAnimation_();
201 this.itemsChild.textContent = '';
202 this.infoChild.hidden = true;
203 this.selectedIndex_ = -1;
204 this.itemList_ = [];
205 if (this.origin)
206 this.origin.createItems(this);
207 this.itemsChild.appendChild(this.infoChild);
208 this.enableAnimation_();
209 },
210
211 /**
212 * Append a new intents node "bubble" to this list item.
213 * @param {IntentsTreeNode} node The intents node to add a bubble for.
214 * @param {Element} div The DOM element for the bubble itself.
215 * @return {number} The index the bubble was added at.
216 */
217 appendItem: function(node, div) {
218 this.itemList_.push({node: node, div: div});
219 this.itemsChild.appendChild(div);
220 return this.itemList_.length - 1;
221 },
222
223 /**
224 * The currently selected intents node ("intents bubble") index.
225 * @type {number}
226 * @private
227 */
228 selectedIndex_: -1,
229
230 /**
231 * Get the currently selected intents node ("intents bubble") index.
232 * @type {number}
233 */
234 get selectedIndex() {
235 return this.selectedIndex_;
236 },
237
238 /**
239 * Set the currently selected intents node ("intents bubble") index to
240 * @{code itemIndex}, unselecting any previously selected node first.
241 * @param {number} itemIndex The index to set as the selected index.
242 * TODO: KILL THIS
243 */
244 set selectedIndex(itemIndex) {
245 // Get the list index up front before we change anything.
246 var index = this.list.getIndexOfListItem(this);
247 // Unselect any previously selected item.
248 if (this.selectedIndex_ >= 0) {
249 var item = this.itemList_[this.selectedIndex_];
250 if (item && item.div)
251 item.div.removeAttribute('selected');
252 }
253 // Special case: decrementing -1 wraps around to the end of the list.
254 if (itemIndex == -2)
255 itemIndex = this.itemList_.length - 1;
256 // Check if we're going out of bounds and hide the item details.
257 if (itemIndex < 0 || itemIndex >= this.itemList_.length) {
258 this.selectedIndex_ = -1;
259 this.disableAnimation_();
260 this.infoChild.hidden = true;
261 this.enableAnimation_();
262 return;
263 }
264 // Set the new selected item and show the item details for it.
265 this.selectedIndex_ = itemIndex;
266 this.itemList_[itemIndex].div.setAttribute('selected', '');
267 this.disableAnimation_();
268 this.infoChild.hidden = false;
269 this.enableAnimation_();
270 // If we're near the bottom of the list this may cause the list item to go
271 // beyond the end of the visible area. Fix it after the animation is done.
272 var list = this.list;
273 window.setTimeout(function() { list.scrollIndexIntoView(index); }, 150);
274 },
275 };
276
277 /**
278 * {@code IntentsTreeNode}s mirror the structure of the intents tree lazily,
279 * and contain all the actual data used to generate the
280 * {@code IntentsListItem}s.
281 * @param {Object} data The data object for this node.
282 * @constructor
283 */
284 function IntentsTreeNode(data) {
285 this.data = data;
286 this.children = [];
287 }
288
289 IntentsTreeNode.prototype = {
290 /**
291 * Insert an intents tree node at the given index.
292 * Both IntentsList and IntentsTreeNode implement this API.
293 * @param {Object} data The data object for the node to add.
294 * @param {number} index The index at which to insert the node.
295 */
296 insertAt: function(data, index) {
297 console.log('IntentsTreeNode.insertAt adding ' +
298 JSON.stringify(data) + ' at ' + index);
299 var child = new IntentsTreeNode(data);
300 this.children.splice(index, 0, child);
301 child.parent = this;
302 this.updateOrigin();
303 },
304
305 /**
306 * Remove an intents tree node from the given index.
307 * Both IntentsList and IntentsTreeNode implement this API.
308 * @param {number} index The index of the tree node to remove.
309 */
310 remove: function(index) {
311 if (index < this.children.length) {
312 this.children.splice(index, 1);
313 this.updateOrigin();
314 }
315 },
316
317 /**
318 * Clears all children.
319 * Both IntentsList and IntentsTreeNode implement this API.
320 * It is used by IntentsList.loadChildren().
321 */
322 clear: function() {
323 // We might leave some garbage in parentLookup for removed children.
324 // But that should be OK because parentLookup is cleared when we
325 // reload the tree.
326 this.children = [];
327 this.updateOrigin();
328 },
329
330 /**
331 * The counter used by startBatchUpdates() and endBatchUpdates().
332 * @type {number}
333 */
334 batchCount_: 0,
335
336 /**
337 * See cr.ui.List.startBatchUpdates().
338 * Both IntentsList (via List) and IntentsTreeNode implement this API.
339 */
340 startBatchUpdates: function() {
341 this.batchCount_++;
342 },
343
344 /**
345 * See cr.ui.List.endBatchUpdates().
346 * Both IntentsList (via List) and IntentsTreeNode implement this API.
347 */
348 endBatchUpdates: function() {
349 if (!--this.batchCount_)
350 this.updateOrigin();
351 },
352
353 /**
354 * Requests updating the origin summary to reflect changes in this item.
355 * Both IntentsListItem and IntentsTreeNode implement this API.
356 */
357 updateOrigin: function() {
358 if (!this.batchCount_ && this.parent)
359 this.parent.updateOrigin();
360 },
361
362 /**
363 * Create the intents services rows for this node.
364 * Append the rows to @{code item}.
365 * @param {IntentsListItem} item The intents list item to create items in.
366 */
367 createItems: function(item) {
368 if (this.children.length > 0) {
369 for (var i = 0; i < this.children.length; ++i)
370 this.children[i].createItems(item);
371 } else if (this.data && !this.data.hasChildren) {
372 var div = item.ownerDocument.createElement('div');
373 div.className = 'intents-item';
374 // Help out screen readers and such: this is a clickable thing.
375 div.setAttribute('role', 'button');
376
377 var divAction = item.ownerDocument.createElement('div');
378 divAction.className = 'intents-item-action';
379 divAction.textContent = this.data.action;
380 div.appendChild(divAction);
381
382 var divTypes = item.ownerDocument.createElement('div');
383 divTypes.className = 'intents-item-types';
384 var text = "";
385 for (var i = 0; i < this.data.types.length; ++i) {
386 if (text != "")
387 text += ", ";
388 text += this.data.types[i];
389 }
390 divTypes.textContent = text;
391 div.appendChild(divTypes);
392
393 var divUrl = item.ownerDocument.createElement('div');
394 divUrl.className = 'intents-item-url';
395 divUrl.textContent = this.data.url;
396 div.appendChild(divUrl);
397
398 var index = item.appendItem(this, div);
399 div.onclick = function() {
400 if (item.selectedIndex == index)
401 item.selectedIndex = -1;
402 else
403 item.selectedIndex = index;
404 };
405 }
406 },
407
408 /**
409 * The parent of this intents tree node.
410 * @type {?IntentsTreeNode|IntentsListItem}
411 */
412 get parent(parent) {
413 // See below for an explanation of this special case.
414 if (typeof this.parent_ == 'number')
415 return this.list_.getListItemByIndex(this.parent_);
416 return this.parent_;
417 },
418 set parent(parent) {
419 if (parent == this.parent)
420 return;
421
422 if (parent instanceof IntentsListItem) {
423 // If the parent is to be a IntentsListItem, then we keep the reference
424 // to it by its containing list and list index, rather than directly.
425 // This allows the list items to be garbage collected when they scroll
426 // out of view (except the expanded item, which we cache). This is
427 // transparent except in the setter and getter, where we handle it.
428 this.parent_ = parent.listIndex;
429 this.list_ = parent.list;
430 parent.addEventListener('listIndexChange',
431 this.parentIndexChanged_.bind(this));
432 } else {
433 this.parent_ = parent;
434 }
435
436
437 if (parent)
438 parentLookup[this.pathId] = this;
439 else
440 delete parentLookup[this.pathId];
441
442 if (this.data && this.data.hasChildren &&
443 !this.children.length && !lookupRequests[this.pathId]) {
444 console.log('SENDING loadIntents');
445 lookupRequests[this.pathId] = true;
446 chrome.send('loadIntents', [this.pathId]);
447 }
448 },
449
450 /**
451 * Called when the parent is a IntentsListItem whose index has changed.
452 * See the code above that avoids keeping a direct reference to
453 * IntentsListItem parents, to allow them to be garbage collected.
454 * @private
455 */
456 parentIndexChanged_: function(event) {
457 if (typeof this.parent_ == 'number') {
458 this.parent_ = event.newValue;
459 // We set a timeout to update the origin, rather than doing it right
460 // away, because this callback may occur while the list items are
461 // being repopulated following a scroll event. Calling updateOrigin()
462 // immediately could trigger relayout that would reset the scroll
463 // position within the list, among other things.
464 window.setTimeout(this.updateOrigin.bind(this), 0);
465 }
466 },
467
468 /**
469 * The intents tree path id.
470 * @type {string}
471 */
472 get pathId() {
473 var parent = this.parent;
474 if (parent && parent instanceof IntentsTreeNode)
475 return parent.pathId + ',' + this.data.action;
476 return this.data.site;
477 },
478 };
479
480 /**
481 * Creates a new intents list.
482 * @param {Object=} opt_propertyBag Optional properties.
483 * @constructor
484 * @extends {DeletableItemList}
485 */
486 var IntentsList = cr.ui.define('list');
487
488 IntentsList.prototype = {
489 __proto__: DeletableItemList.prototype,
490
491 /** @inheritDoc */
492 decorate: function() {
493 DeletableItemList.prototype.decorate.call(this);
494 this.classList.add('intents-list');
495 this.data_ = [];
496 this.dataModel = new ArrayDataModel(this.data_);
497 this.addEventListener('keydown', this.handleKeyLeftRight_.bind(this));
498 var sm = new ListSingleSelectionModel();
499 sm.addEventListener('change', this.cookieSelectionChange_.bind(this));
500 sm.addEventListener('leadIndexChange', this.cookieLeadChange_.bind(this));
501 this.selectionModel = sm;
502 },
503
504 /**
505 * Handles key down events and looks for left and right arrows, then
506 * dispatches to the currently expanded item, if any.
507 * @param {Event} e The keydown event.
508 * @private
509 */
510 handleKeyLeftRight_: function(e) {
511 var id = e.keyIdentifier;
512 if ((id == 'Left' || id == 'Right') && this.expandedItem) {
513 var cs = this.ownerDocument.defaultView.getComputedStyle(this);
514 var rtl = cs.direction == 'rtl';
515 if ((!rtl && id == 'Left') || (rtl && id == 'Right'))
516 this.expandedItem.selectedIndex--;
517 else
518 this.expandedItem.selectedIndex++;
519 this.scrollIndexIntoView(this.expandedItem.listIndex);
520 // Prevent the page itself from scrolling.
521 e.preventDefault();
522 }
523 },
524
525 /**
526 * Called on selection model selection changes.
527 * @param {Event} ce The selection change event.
528 * @private
529 */
530 cookieSelectionChange_: function(ce) {
531 ce.changes.forEach(function(change) {
532 var listItem = this.getListItemByIndex(change.index);
533 if (listItem) {
534 if (!change.selected) {
535 // We set a timeout here, rather than setting the item unexpanded
536 // immediately, so that if another item gets set expanded right
537 // away, it will be expanded before this item is unexpanded. It
538 // will notice that, and unexpand this item in sync with its own
539 // expansion. Later, this callback will end up having no effect.
540 window.setTimeout(function() {
541 if (!listItem.selected || !listItem.lead)
542 listItem.expanded = false;
543 }, 0);
544 } else if (listItem.lead) {
545 listItem.expanded = true;
546 }
547 }
548 }, this);
549 },
550
551 /**
552 * Called on selection model lead changes.
553 * @param {Event} pe The lead change event.
554 * @private
555 */
556 cookieLeadChange_: function(pe) {
557 if (pe.oldValue != -1) {
558 var listItem = this.getListItemByIndex(pe.oldValue);
559 if (listItem) {
560 // See cookieSelectionChange_ above for why we use a timeout here.
561 window.setTimeout(function() {
562 if (!listItem.lead || !listItem.selected)
563 listItem.expanded = false;
564 }, 0);
565 }
566 }
567 if (pe.newValue != -1) {
568 var listItem = this.getListItemByIndex(pe.newValue);
569 if (listItem && listItem.selected)
570 listItem.expanded = true;
571 }
572 },
573
574 /**
575 * The currently expanded item. Used by IntentsListItem above.
576 * @type {?IntentsListItem}
577 */
578 expandedItem: null,
579
580 // from cr.ui.List
581 /** @inheritDoc */
582 createItem: function(data) {
583 // We use the cached expanded item in order to allow it to maintain some
584 // state (like its fixed height, and which bubble is selected).
585 if (this.expandedItem && this.expandedItem.origin == data)
586 return this.expandedItem;
587 return new IntentsListItem(data, this);
588 },
589
590 // from options.DeletableItemList
591 /** @inheritDoc */
592 deleteItemAtIndex: function(index) {
593 var item = this.data_[index];
594 if (item) {
595 var pathId = item.pathId;
596 if (pathId)
597 chrome.send('removeIntent', [pathId]);
598 }
599 },
600
601 /**
602 * Insert an intents tree node at the given index.
603 * Both IntentsList and IntentsTreeNode implement this API.
604 * @param {Object} data The data object for the node to add.
605 * @param {number} index The index at which to insert the node.
606 */
607 insertAt: function(data, index) {
608 this.dataModel.splice(index, 0, new IntentsTreeNode(data));
609 },
610
611 /**
612 * Remove an intents tree node from the given index.
613 * Both IntentsList and IntentsTreeNode implement this API.
614 * @param {number} index The index of the tree node to remove.
615 */
616 remove: function(index) {
617 if (index < this.data_.length)
618 this.dataModel.splice(index, 1);
619 },
620
621 /**
622 * Clears the list.
623 * Both IntentsList and IntentsTreeNode implement this API.
624 * It is used by IntentsList.loadChildren().
625 */
626 clear: function() {
627 parentLookup = {};
628 this.data_ = [];
629 this.dataModel = new ArrayDataModel(this.data_);
630 this.redraw();
631 },
632
633 /**
634 * Add tree nodes by given parent.
635 * Note: this method will be O(n^2) in the general case. Use it only to
636 * populate an empty parent or to insert single nodes to avoid this.
637 * @param {Object} parent The parent node.
638 * @param {number} start Start index of where to insert nodes.
639 * @param {Array} nodesData Nodes data array.
640 * @private
641 */
642 addByParent_: function(parent, start, nodesData) {
643 if (!parent)
644 return;
645
646 parent.startBatchUpdates();
647 for (var i = 0; i < nodesData.length; ++i)
648 parent.insertAt(nodesData[i], start + i);
649 parent.endBatchUpdates();
650
651 cr.dispatchSimpleEvent(this, 'change');
652 },
653
654 /**
655 * Add tree nodes by parent id.
656 * This is used by intents_view.js.
657 * Note: this method will be O(n^2) in the general case. Use it only to
658 * populate an empty parent or to insert single nodes to avoid this.
659 * @param {string} parentId Id of the parent node.
660 * @param {number} start Start index of where to insert nodes.
661 * @param {Array} nodesData Nodes data array.
662 */
663 addByParentId: function(parentId, start, nodesData) {
664 var parent = parentId ? parentLookup[parentId] : this;
665 this.addByParent_(parent, start, nodesData);
666 },
667
668 /**
669 * Removes tree nodes by parent id.
670 * This is used by intents_view.js.
671 * @param {string} parentId Id of the parent node.
672 * @param {number} start Start index of nodes to remove.
673 * @param {number} count Number of nodes to remove.
674 */
675 removeByParentId: function(parentId, start, count) {
676 var parent = parentId ? parentLookup[parentId] : this;
677 if (!parent)
678 return;
679
680 parent.startBatchUpdates();
681 while (count-- > 0)
682 parent.remove(start);
683 parent.endBatchUpdates();
684
685 cr.dispatchSimpleEvent(this, 'change');
686 },
687
688 /**
689 * Loads the immediate children of given parent node.
690 * This is used by intents_view.js.
691 * @param {string} parentId Id of the parent node.
692 * @param {Array} children The immediate children of parent node.
693 */
694 loadChildren: function(parentId, children) {
695 console.log('Loading intents view: ' +
696 parentId + ' ' + JSON.stringify(children));
697 if (parentId)
698 delete lookupRequests[parentId];
699 var parent = parentId ? parentLookup[parentId] : this;
700 if (!parent)
701 return;
702
703 parent.startBatchUpdates();
704 parent.clear();
705 this.addByParent_(parent, 0, children);
706 parent.endBatchUpdates();
707 },
708 };
709
710 return {
711 IntentsList: IntentsList
712 };
713 });
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698