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

Side by Side Diff: tools/dom/src/TemplateBindings.dart

Issue 14732003: Implement Model-Driven-Views spec for Dart (Closed) Base URL: https://dart.googlecode.com/svn/branches/bleeding_edge/dart
Patch Set: trying upload again Created 7 years, 7 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) 2013, the Dart project authors. Please see the AUTHORS file
2 // for details. All rights reserved. Use of this source code is governed by a
3 // BSD-style license that can be found in the LICENSE file.
4
5 part of html;
6
7 // This code is a port of Model-Driven-Views:
8 // https://github.com/toolkitchen/mdv
9 // The code mostly comes from src/template_element.js
Jennifer Messerly 2013/05/01 00:44:58 What's up with JavaStyle file names in tools/dom/s
blois 2013/05/01 17:00:42 We should rename en-masse. It keeps on bugging me,
Jennifer Messerly 2013/05/02 02:58:33 sounds good to me
10
11 typedef void _ChangeHandler(value);
12
13 /**
14 * Model-Driven Views (MDV)'s native features enables a wide-range of use cases,
15 * but (by design) don't attempt to implement a wide array of specialized
16 * behaviors.
17 *
18 * Enabling these features in MDV is a matter of implementing and registering an
19 * MDV Custom Syntax. A Custom Syntax is an object which contains one or more
20 * delegation functions which implement specialized behavior. This object is
21 * registered with MDV via [TemplateElement.syntax]:
22 *
23 *
24 * HTML:
25 * <template bind syntax="MySyntax">
26 * {{ What!Ever('crazy')->thing^^^I+Want(data) }}
27 * </template>
28 *
29 * Dart:
30 * class MySyntax extends CustomBindingSyntax {
31 * getBinding(model, path, name, node) {
32 * // The magic happens here!
33 * }
34 * }
35 *
36 * ...
37 *
38 * TemplateElement.syntax['MySyntax'] = new MySyntax();
39 *
40 * See <https://github.com/toolkitchen/mdv/blob/master/docs/syntax.md> for more
41 * information about Custom Syntax.
42 */
43 // TODO(jmesserly): if this is just one method, a function type would make it
44 // more Dart-friendly.
45 @Experimental
46 abstract class CustomBindingSyntax {
47 // TODO(jmesserly): I had to remove type annotations from "name" and "node"
48 // Normally they are String and Node respectively. But sometimes it will pass
49 // (int name, CompoundBinding node). That seems very confusing; we may want
50 // to change this API.
51 getBinding(model, String path, name, node);
52 }
53
54 /** The callback used in the [CompoundBinding.combinator] field. */
55 @Experimental
56 typedef Object CompoundBindingCombinator(Map objects);
57
58 /** Information about the instantiated template. */
59 @Experimental
60 class TemplateInstance {
61 // TODO(rafaelw): firstNode & lastNode should be read-synchronous
62 // in cases where script has modified the template instance boundary.
63
64 /** The first node of this template instantiation. */
65 final Node firstNode;
66
67 /**
68 * The last node of this template instantiation.
69 * This could be identical to [firstNode] if the template only expanded to a
70 * single node.
71 */
72 final Node lastNode;
73
74 /** The model used to instantiate the template. */
75 final model;
76
77 TemplateInstance(this.firstNode, this.lastNode, this.model);
78 }
79
80 /**
81 * Model-Driven Views contains a helper object which is useful for the
82 * implementation of a Custom Syntax.
83 *
84 * var binding = new CompoundBinding((values) {
85 * var combinedValue;
86 * // compute combinedValue based on the current values which are provided
87 * return combinedValue;
88 * });
89 * binding.bind('name1', obj1, path1);
90 * binding.bind('name2', obj2, path2);
91 * //...
92 * binding.bind('nameN', objN, pathN);
93 *
94 * CompoundBinding is an object which knows how to listen to multiple path
95 * values (registered via [bind]) and invoke its [combinator] when one or more
96 * of the values have changed and set its [value] property to the return value
97 * of the function. When any value has changed, all current values are provided
98 * to the [combinator] in the single `values` argument.
99 *
100 * See [CustomBindingSyntax] for more information.
101 */
102 // TODO(jmesserly): what is the public API surface here? I just guessed;
103 // most of it seemed non-public.
104 // TODO(jmesserly): should this move into dart:observe?
105 @Experimental
106 class CompoundBinding extends ObservableMixin {
107 CompoundBindingCombinator _combinator;
108
109 // TODO(jmesserly): ideally these would be String keys, but sometimes we
110 // use integers.
111 Map<dynamic, _Binding> _bindings = {};
112 Map _values = {};
113 bool _scheduled = false;
114 bool _disposed = false;
115 Object _value;
116
117 CompoundBinding([CompoundBindingCombinator combinator]) {
118 // TODO(jmesserly): this is a tweak to the original code, it seemed to me
119 // that passing the combinator to the constructor should be equivalent to
120 // setting it via the property.
121 // I also added a null check to the combinator setter.
122 this.combinator = combinator;
123 }
124
125 CompoundBindingCombinator get combinator => _combinator;
126
127 set combinator(CompoundBindingCombinator combinator) {
128 _combinator = combinator;
129 if (combinator != null) _scheduleResolve();
130 }
131
132 static const _VALUE = 'value';
133
134 get value => _value;
135
136 void set value(newValue) {
137 _value = notifyChange('value', _value, newValue);
138 }
139
140 // TODO(jmesserly): remove getValue/setValue when dart2js supports mirrors!
141 getValue(key) {
142 if (key == _VALUE) return value;
143 return null;
144 }
145 setValue(key, val) {
146 if (key == _VALUE) value = val;
147 }
148
149 void bind(name, model, String path) {
150 unbind(name);
151
152 _bindings[name] = new _Binding(model, path, (value) {
153 _values[name] = value;
154 _scheduleResolve();
155 });
156 }
157
158 void unbind(name, {bool suppressResolve: false}) {
159 var binding = _bindings.remove(name);
160 if (binding == null) return;
161
162 binding.dispose();
163 _values.remove(name);
164 if (!suppressResolve) _scheduleResolve();
165 }
166
167 // TODO(rafaelw): Is this the right processing model?
168 // TODO(rafaelw): Consider having a seperate ChangeSummary for
169 // CompoundBindings so to excess dirtyChecks.
170 void _scheduleResolve() {
171 if (_scheduled) return;
172 _scheduled = true;
173 queueChangeRecords(resolve);
174 }
175
176 void resolve() {
177 if (_disposed) return;
178 _scheduled = false;
179
180 if (_combinator == null) {
181 throw new StateError(
182 'CompoundBinding attempted to resolve without a combinator');
183 }
184
185 value = _combinator(_values);
186 }
187
188 void dispose() {
189 for (var binding in _bindings.values) {
190 binding.dispose();
191 }
192 _bindings.clear();
193 _values.clear();
194
195 _disposed = true;
196 value = null;
197 }
198 }
199
200 // TODO(jmesserly): we can probably inline this object as it doesn't do much.
201 class _Binding {
202 final _ChangeHandler changed;
203 final PathObserver _path;
204 StreamSubscription _sub;
205
206 _Binding(model, String path, _ChangeHandler changed)
207 : _path = observePath(model, path) {
208 if (_path == null) {
209 // TODO(jmesserly): Should we display an error message about invalid path?
210 changed(null);
211 return;
212 }
213 _sub = _path.values.listen(changed);
214 changed(_path.value);
215 }
216
217 void dispose() {
218 if (_sub != null) _sub.cancel();
219 }
220
221 void set value(newValue) {
222 if (_path != null) _path.value = newValue;
223 }
224 }
225
226 Stream<Event> _getStreamForInputType(InputElement element) {
227 switch (element.type) {
228 case 'checkbox':
229 return element.onClick;
230 case 'radio':
231 case 'select-multiple':
232 case 'select-one':
233 return element.onChange;
234 default:
235 return element.onInput;
236 }
237 }
238
239 abstract class _InputBinding {
240 final InputElement element;
241 _Binding binding;
242 StreamSubscription _sub;
243
244 _InputBinding(this.element, model, String path) {
245 binding = new _Binding(model, path, valueChanged);
246 _sub = _getStreamForInputType(element).listen(updateBinding);
247 }
248
249 void valueChanged(newValue);
250
251 void updateBinding(e);
252
253 void unbind() {
254 binding.dispose();
255 _sub.cancel();
256 }
257 }
258
259 class _ValueBinding extends _InputBinding {
260 _ValueBinding(element, model, path) : super(element, model, path);
261
262 void valueChanged(value) {
263 element.value = value == null ? '' : '$value';
264 }
265
266 void updateBinding(e) {
267 binding.value = element.value;
268 }
269 }
270
271 // TODO(jmesserly): not sure what kind of boolean conversion rules to
272 // apply for template data-binding. HTML attributes are true if they're present.
273 // However Dart only treats "true" as true. Since this is HTML we'll use
274 // something closer to the HTML rules: null (missing) and false are false,
275 // everything else is true. See: https://github.com/toolkitchen/mdv/issues/59
276 bool _templateBooleanConversion(value) => null != value && false != value;
277
278 class _CheckedBinding extends _InputBinding {
279 _CheckedBinding(element, model, path) : super(element, model, path);
280
281 void valueChanged(value) {
282 element.checked = _templateBooleanConversion(value);
283 }
284
285 void updateBinding(e) {
286 binding.value = element.checked;
287
288 // Only the radio button that is getting checked gets an event. We
289 // therefore find all the associated radio buttons and update their
290 // CheckedBinding manually.
291 if (element is InputElement && element.type == 'radio') {
292 for (var r in _getAssociatedRadioButtons(element)) {
293 var checkedBinding = r._checkedBinding;
294 if (checkedBinding != null) {
295 // Set the value directly to avoid an infinite call stack.
296 checkedBinding.binding.value = false;
297 }
298 }
299 }
300 }
301 }
302
303 // TODO(jmesserly): do we need to polyfill document.contains for IE?
304 bool _isNodeInDocument(Node node) => node.document.contains(node);
305
306 // |element| is assumed to be an HTMLInputElement with |type| == 'radio'.
307 // Returns an array containing all radio buttons other than |element| that
308 // have the same |name|, either in the form that |element| belongs to or,
309 // if no form, in the document tree to which |element| belongs.
310 //
311 // This implementation is based upon the HTML spec definition of a
312 // "radio button group":
313 // http://www.whatwg.org/specs/web-apps/current-work/multipage/number-state.ht ml#radio-button-group
314 //
315 Iterable<Element> _getAssociatedRadioButtons(element) {
316 if (!_isNodeInDocument(element)) return [];
317 if (element.form != null) {
318 return element.form.nodes.where((el) {
319 return el != element &&
320 el is InputElement &&
321 el.type == 'radio' &&
322 el.name == element.name;
323 });
324 } else {
325 var radios = element.document.queryAll(
326 'input[type="radio"][name="${element.name}"]');
327 return radios.where((el) => el != element && el.form == null);
328 }
329 }
330
331 Node _createDeepCloneAndDecorateTemplates(Node node, String syntax) {
332 var clone = node.clone(false); // Shallow clone.
333 if (clone is Element && clone.isTemplate) {
334 TemplateElement.decorate(clone, node);
335 if (syntax != null) {
336 clone.attributes.putIfAbsent('syntax', () => syntax);
337 }
338 }
339
340 for (var c = node.$dom_firstChild; c != null; c = c.nextNode) {
341 clone.nodes.add(_createDeepCloneAndDecorateTemplates(c, syntax));
342 }
343 return clone;
344 }
345
346 // http://dvcs.w3.org/hg/webcomponents/raw-file/tip/spec/templates/index.html#df n-template-contents-owner
347 Document _getTemplateContentsOwner(Document doc) {
348 if (doc.window == null) {
349 return doc;
350 }
351 var d = doc._templateContentsOwner;
352 if (d == null) {
353 // TODO(arv): This should either be a Document or HTMLDocument depending
354 // on doc.
355 d = doc.implementation.createHtmlDocument('');
356 while (d.$dom_lastChild != null) {
357 d.$dom_lastChild.remove();
358 }
359 doc._templateContentsOwner = d;
360 }
361 return d;
362 }
363
364 Element _cloneAndSeperateAttributeTemplate(Element templateElement) {
365 var clone = templateElement.clone(false);
366 var attributes = templateElement.attributes;
367 for (var name in attributes.keys.toList()) {
368 switch (name) {
369 case 'template':
370 case 'repeat':
371 case 'bind':
372 case 'ref':
373 clone.attributes.remove(name);
374 break;
375 default:
376 attributes.remove(name);
377 break;
378 }
379 }
380
381 return clone;
382 }
383
384 void _liftNonNativeTemplateChildrenIntoContent(Element templateElement) {
385 var content = templateElement.content;
386
387 if (!templateElement._isAttributeTemplate) {
388 var child;
389 while ((child = templateElement.$dom_firstChild) != null) {
390 content.nodes.add(child);
391 }
392 return;
393 }
394
395 // For attribute templates we copy the whole thing into the content and
396 // we move the non template attributes into the content.
397 //
398 // <tr foo template>
399 //
400 // becomes
401 //
402 // <tr template>
403 // + #document-fragment
404 // + <tr foo>
405 //
406 var newRoot = _cloneAndSeperateAttributeTemplate(templateElement);
407 var child;
408 while ((child = templateElement.$dom_firstChild) != null) {
409 newRoot.nodes.add(child);
410 }
411 content.nodes.add(newRoot);
412 }
413
414 void _bootstrapTemplatesRecursivelyFrom(Node node) {
415 void bootstrap(template) {
416 if (!TemplateElement.decorate(template)) {
417 _bootstrapTemplatesRecursivelyFrom(template.content);
418 }
419 }
420
421 // Need to do this first as the contents may get lifted if |node| is
422 // template.
423 // TODO(jmesserly): node is DocumentFragment or Element
424 var templateDescendents = (node as dynamic).queryAll(_allTemplatesSelectors);
425 if (node is Element && node.isTemplate) bootstrap(node);
426
427 templateDescendents.forEach(bootstrap);
428 }
429
430 final String _allTemplatesSelectors = 'template, option[template], ' +
431 Element._TABLE_TAGS.keys.map((k) => "$k[template]").join(", ");
432
433 void _addBindings(Node node, model, [CustomBindingSyntax syntax]) {
434 if (node is Element) {
435 _addAttributeBindings(node, model, syntax);
436 } else if (node is Text) {
437 _parseAndBind(node, node.text, 'text', model, syntax);
438 }
439
440 for (var c = node.$dom_firstChild; c != null; c = c.nextNode) {
441 _addBindings(c, model, syntax);
442 }
443 }
444
445
446 void _addAttributeBindings(Element element, model, syntax) {
447 element.attributes.forEach((name, value) {
448 if (value == '' && (name == 'bind' || name == 'repeat')) {
449 value = '{{}}';
450 }
451 _parseAndBind(element, value, name, model, syntax);
452 });
453 }
454
455 void _parseAndBind(Node node, String text, String name, model,
456 CustomBindingSyntax syntax) {
457
458 var tokens = _parseMustacheTokens(text);
459 if (tokens.length == 0 || (tokens.length == 1 && tokens[0].isText)) {
460 return;
461 }
462
463 if (tokens.length == 1 && tokens[0].isBinding) {
464 _bindOrDelegate(node, name, model, tokens[0].value, syntax);
465 return;
466 }
467
468 var replacementBinding = new CompoundBinding();
469 for (var i = 0; i < tokens.length; i++) {
470 var token = tokens[i];
471 if (token.isBinding) {
472 _bindOrDelegate(replacementBinding, i, model, token.value, syntax);
473 }
474 }
475
476 replacementBinding.combinator = (values) {
477 var newValue = new StringBuffer();
478
479 for (var i = 0; i < tokens.length; i++) {
480 var token = tokens[i];
481 if (token.isText) {
482 newValue.write(token.value);
483 } else {
484 var value = values[i];
485 if (value != null) {
486 newValue.write(value);
487 }
488 }
489 }
490
491 return newValue.toString();
492 };
493
494 node.bind(name, replacementBinding, 'value');
495 }
496
497 void _bindOrDelegate(node, name, model, String path,
498 CustomBindingSyntax syntax) {
499
500 if (syntax != null) {
501 var delegateBinding = syntax.getBinding(model, path, name, node);
502 if (delegateBinding != null) {
503 model = delegateBinding;
504 path = 'value';
505 }
506 }
507
508 node.bind(name, model, path);
509 }
510
511 class _BindingToken {
512 final String value;
513 final bool isBinding;
514
515 _BindingToken(this.value, {this.isBinding: false});
516
517 bool get isText => !isBinding;
518 }
519
520 List<_BindingToken> _parseMustacheTokens(String s) {
521 var result = [];
522 var length = s.length;
523 var index = 0, lastIndex = 0;
524 while (lastIndex < length) {
525 index = s.indexOf('{{', lastIndex);
526 if (index < 0) {
527 result.add(new _BindingToken(s.substring(lastIndex)));
528 break;
529 } else {
530 // There is a non-empty text run before the next path token.
531 if (index > 0 && lastIndex < index) {
532 result.add(new _BindingToken(s.substring(lastIndex, index)));
533 }
534 lastIndex = index + 2;
535 index = s.indexOf('}}', lastIndex);
536 if (index < 0) {
537 var text = s.substring(lastIndex - 2);
538 if (result.length > 0 && result.last.isText) {
539 result.last.value += text;
540 } else {
541 result.add(new _BindingToken(text));
542 }
543 break;
544 }
545
546 var value = s.substring(lastIndex, index).trim();
547 result.add(new _BindingToken(value, isBinding: true));
548 lastIndex = index + 2;
549 }
550 }
551 return result;
552 }
553
554 void _addTemplateInstanceRecord(fragment, model) {
555 if (fragment.$dom_firstChild == null) {
556 return;
557 }
558
559 var instanceRecord = new TemplateInstance(
560 fragment.$dom_firstChild, fragment.$dom_lastChild, model);
561
562 var node = instanceRecord.firstNode;
563 while (node != null) {
564 node._templateInstance = instanceRecord;
565 node = node.nextNode;
566 }
567 }
568
569 void _removeAllBindingsRecursively(Node node) {
570 node.unbindAll();
571 for (var c = node.$dom_firstChild; c != null; c = c.nextNode) {
572 _removeAllBindingsRecursively(c);
573 }
574 }
575
576 void _removeTemplateChild(Node parent, Node child) {
577 child._templateInstance = null;
578 if (child is Element && child.isTemplate) {
579 // Make sure we stop observing when we remove an element.
580 var templateIterator = child._templateIterator;
581 if (templateIterator != null) {
582 templateIterator.abandon();
583 child._templateIterator = null;
584 }
585 }
586 child.remove();
587 _removeAllBindingsRecursively(child);
588 }
589
590 class _InstanceCursor {
591 final Element _template;
592 Node _terminator;
593 Node _previousTerminator;
594 int _previousIndex = -1;
595 int _index = 0;
596
597 _InstanceCursor(this._template, [index]) {
598 _terminator = _template;
599 if (index != null) {
600 while (index-- > 0) {
601 next();
602 }
603 }
604 }
605
606 void next() {
607 _previousTerminator = _terminator;
608 _previousIndex = _index;
609 _index++;
610
611 while (_index > _terminator._instanceTerminatorCount) {
612 _index -= _terminator._instanceTerminatorCount;
613 _terminator = _terminator.nextNode;
614 if (_terminator is Element && _terminator.tagName == 'TEMPLATE') {
615 _index += _instanceCount(_terminator);
616 }
617 }
618 }
619
620 void abandon() {
621 assert(_instanceCount(_template) > 0);
622 assert(_terminator._instanceTerminatorCount > 0);
623 assert(_index > 0);
624
625 _terminator._instanceTerminatorCount--;
626 _index--;
627 }
628
629 void insert(fragment) {
630 assert(_template.parentNode != null);
631
632 _previousTerminator = _terminator;
633 _previousIndex = _index;
634 _index++;
635
636 _terminator = fragment.$dom_lastChild;
637 if (_terminator == null) _terminator = _previousTerminator;
638 _template.parentNode.insertBefore(fragment, _previousTerminator.nextNode);
639
640 _terminator._instanceTerminatorCount++;
641 if (_terminator != _previousTerminator) {
642 while (_previousTerminator._instanceTerminatorCount >
643 _previousIndex) {
644 _previousTerminator._instanceTerminatorCount--;
645 _terminator._instanceTerminatorCount++;
646 }
647 }
648 }
649
650 void remove() {
651 assert(_previousIndex != -1);
652 assert(_previousTerminator != null &&
653 (_previousIndex > 0 || _previousTerminator == _template));
654 assert(_terminator != null && _index > 0);
655 assert(_template.parentNode != null);
656 assert(_instanceCount(_template) > 0);
657
658 if (_previousTerminator == _terminator) {
659 assert(_index == _previousIndex + 1);
660 _terminator._instanceTerminatorCount--;
661 _terminator = _template;
662 _previousTerminator = null;
663 _previousIndex = -1;
664 return;
665 }
666
667 _terminator._instanceTerminatorCount--;
668
669 var parent = _template.parentNode;
670 while (_previousTerminator.nextNode != _terminator) {
671 _removeTemplateChild(parent, _previousTerminator.nextNode);
672 }
673 _removeTemplateChild(parent, _terminator);
674
675 _terminator = _previousTerminator;
676 _index = _previousIndex;
677 _previousTerminator = null;
678 _previousIndex = -1; // 0?
679 }
680 }
681
682
683 class _TemplateIterator {
684 final Element _templateElement;
685 int instanceCount = 0;
686 List iteratedValue;
687 bool observing = false;
688 CompoundBinding inputs;
689 _Binding valueBinding;
690 StreamSubscription _sub;
691
692 _TemplateIterator(this._templateElement) {
693 inputs = new CompoundBinding(resolveInputs);
694 valueBinding = new _Binding(inputs, 'value', valueChanged);
695 }
696
697 Object resolveInputs(Map values) {
698 if (values.containsKey('if') && !_templateBooleanConversion(values['if'])) {
699 return null;
700 }
701
702 if (values.containsKey('repeat')) {
703 return values['repeat'];
704 }
705
706 if (values.containsKey('bind')) {
707 return [values['bind']];
708 }
709
710 return null;
711 }
712
713 void valueChanged(value) {
714 clear();
715 if (value is! List) return;
716
717 iteratedValue = value;
718
719 if (value is Observable) {
720 _sub = value.changes.listen(handleChange);
721 }
722
723 handleSplices([new ListChangeDelta(0, addedCount: iteratedValue.length)]);
724 }
725
726 // TODO(jmesserly): these properties appear not be finished. I think it's
727 // part of custom syntax like repeat="i in items".
728 getInstanceModel(model, syntax) => model;
729 getInstanceFragment(syntax) => _templateElement.createInstance();
730
731 void handleChange(List<ChangeRecord> records) {
732 handleSplices(summarizeListChanges(iteratedValue, records));
733 }
734
735 void handleSplices(List<ListChangeDelta> splices) {
736 var syntax = TemplateElement.syntax[_templateElement.attributes['syntax']];
737
738 for (var splice in splices) {
739 for (var removed in splice.removed) {
740 var cursor = new _InstanceCursor(_templateElement, splice.index + 1);
741 cursor.remove();
742 instanceCount--;
743 }
744
745 for (var addIndex = splice.index;
746 addIndex < splice.index + splice.addedCount;
747 addIndex++) {
748
749 var model = getInstanceModel(iteratedValue[addIndex], syntax);
750 var fragment = getInstanceFragment(syntax);
751
752 _addBindings(fragment, model, syntax);
753 _addTemplateInstanceRecord(fragment, model);
754
755 var cursor = new _InstanceCursor(_templateElement, addIndex);
756 cursor.insert(fragment);
757 instanceCount++;
758 }
759 }
760 }
761
762 void unobserve() {
763 if (_sub == null) return;
764 _sub.cancel();
765 _sub = null;
766 }
767
768 void clear() {
769 unobserve();
770
771 iteratedValue = null;
772 if (instanceCount == 0) return;
773
774 for (var i = 0; i < instanceCount; i++) {
775 var cursor = new _InstanceCursor(_templateElement, 1);
776 cursor.remove();
777 }
778
779 instanceCount = 0;
780 }
781
782 void abandon() {
783 unobserve();
784 valueBinding.dispose();
785 inputs.dispose();
786 }
787 }
788
789 int _instanceCount(Element element) {
790 var templateIterator = element._templateIterator;
791 return templateIterator != null ? templateIterator.instanceCount : 0;
792 }
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698