OLD | NEW |
| (Empty) |
1 // Copyright (c) 2011, 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 // TODO(jacobr): there is a lot of dead code in this class. Checking is as is | |
6 // and then doing a large pass to remove functionality that doesn't make sense | |
7 // given the UI layout. | |
8 | |
9 /** | |
10 * Front page of Swarm. | |
11 */ | |
12 // TODO(jacobr): this code now needs a large refactoring. | |
13 // Suggested refactorings: | |
14 // Move animation specific code into helper classes. | |
15 class FrontView extends CompositeView { | |
16 final Swarm swarm; | |
17 | |
18 /** View containing all UI anchored to the top of the page. */ | |
19 CompositeView topView; | |
20 /** View containing all UI anchored to the left side of the page. */ | |
21 CompositeView bottomView; | |
22 HeaderView headerView; | |
23 SliderMenu sliderMenu; | |
24 | |
25 /** | |
26 * When the user is viewing a story, the data source for that story is | |
27 * detached from the section and shown at the bottom of the screen. This keeps | |
28 * track of that so we can restore it later. | |
29 */ | |
30 DataSourceView detachedView; | |
31 | |
32 /** | |
33 * Map from section title to the View that shows this section. This | |
34 * is populated lazily. | |
35 */ | |
36 StoryContentView storyView; | |
37 bool nextPrevShown; | |
38 | |
39 ConveyorView sections; | |
40 | |
41 /** | |
42 * The set of keys that produce a given behavior (going down one story, | |
43 * navigating to the column to the right, etc). | |
44 */ | |
45 //TODO(jmesserly): we need a key code enumeration | |
46 final Set downKeyPresses; | |
47 final Set upKeyPresses; | |
48 final Set rightKeyPresses; | |
49 final Set leftKeyPresses; | |
50 final Set openKeyPresses; | |
51 final Set backKeyPresses; | |
52 final Set nextPageKeyPresses; | |
53 final Set previousPageKeyPresses; | |
54 | |
55 FrontView(this.swarm) | |
56 : super('front-view fullpage'), | |
57 downKeyPresses = new Set.from([74 /*j*/, 40 /*down*/]), | |
58 upKeyPresses = new Set.from([75 /*k*/, 38 /*up*/]), | |
59 rightKeyPresses = new Set.from([39 /*right*/, 68 /*d*/, 76 /*l*/]), | |
60 leftKeyPresses = new Set.from([37 /*left*/, 65 /*a*/, 72 /*h*/]), | |
61 openKeyPresses = new Set.from([13 /*enter*/, 79 /*o*/]), | |
62 backKeyPresses = new Set.from([8 /*delete*/, 27 /*escape*/]), | |
63 nextPageKeyPresses = new Set.from([78 /*n*/]), | |
64 previousPageKeyPresses = new Set.from([80 /*p*/]), | |
65 nextPrevShown = false { | |
66 topView = new CompositeView('top-view', false, false, false); | |
67 | |
68 headerView = new HeaderView(swarm); | |
69 topView.addChild(headerView); | |
70 | |
71 sliderMenu = new SliderMenu(swarm.sections.sectionTitles, | |
72 (sectionTitle) { | |
73 swarm.state.moveToNewSection(sectionTitle); | |
74 _onSectionSelected(sectionTitle); | |
75 // Start with no articles selected. | |
76 swarm.state.selectedArticle.value = null; | |
77 }); | |
78 topView.addChild(sliderMenu); | |
79 addChild(topView); | |
80 | |
81 bottomView = new CompositeView('bottom-view', false, false, false); | |
82 addChild(bottomView); | |
83 | |
84 sections = new ConveyorView(); | |
85 sections.viewSelected = _onSectionTransitionEnded; | |
86 } | |
87 | |
88 SectionView get currentSection() { | |
89 var view = sections.selectedView; | |
90 // TODO(jmesserly): this code works around a bug in the DartC --optimize | |
91 if (view == null) { | |
92 view = sections.childViews[0]; | |
93 sections.selectView(view); | |
94 } | |
95 return view; | |
96 } | |
97 | |
98 void afterRender(Element node) { | |
99 _createSectionViews(); | |
100 attachWatch(swarm.state.currentArticle, (e) { _refreshCurrentArticle(); }); | |
101 attachWatch(swarm.state.storyMaximized, (e) { _refreshMaximized(); }); | |
102 } | |
103 | |
104 void _refreshCurrentArticle() { | |
105 if (!swarm.state.inMainView) { | |
106 _animateToStory(swarm.state.currentArticle.value); | |
107 } else { | |
108 _animateToMainView(); | |
109 } | |
110 } | |
111 | |
112 /** | |
113 * Animates back from the story view to the main grid view. | |
114 */ | |
115 void _animateToMainView() { | |
116 sliderMenu.removeClass('hidden'); | |
117 storyView.addClass('hidden-story'); | |
118 currentSection.storyMode = false; | |
119 | |
120 headerView.startTransitionToMainView(); | |
121 | |
122 currentSection.dataSourceView.reattachSubview( | |
123 detachedView.source, detachedView, true); | |
124 | |
125 storyView.node.on.transitionEnd.add(handler(e) { | |
126 // Only listen once. | |
127 // TODO(rnystrom): Look into adding .once() to EventListenerList to allow | |
128 // this for any event. | |
129 storyView.node.on.transitionEnd.remove(handler, false); | |
130 | |
131 currentSection.hidden = false; | |
132 // TODO(rnystrom): Should move this "mode" into SwarmState and have | |
133 // header view respond to change events itself. | |
134 removeChild(storyView); | |
135 storyView = null; | |
136 detachedView.removeClass('sel'); | |
137 detachedView = null; | |
138 }); | |
139 } | |
140 | |
141 void _animateToStory(Article item) { | |
142 final source = item.dataSource; | |
143 | |
144 if (detachedView != null && detachedView.source != source) { | |
145 // Ignore spurious item selection clicks that occur while a data source | |
146 // is already selected. These are likely clicks that occur while an | |
147 // animation is in progress. | |
148 return; | |
149 } | |
150 | |
151 if (storyView != null) { | |
152 // Remove the old story. This happens if we're already in the Story View | |
153 // and the user has clicked to see a new story. | |
154 removeChild(storyView); | |
155 | |
156 // Create the new story view and place in the frame. | |
157 storyView = addChild(new StoryContentView(swarm, item)); | |
158 } else { | |
159 // We are animating from the main view to the story view. | |
160 // TODO(jmesserly): make this code better | |
161 final view = currentSection.findView(source); | |
162 | |
163 final newPosition = FxUtil.computeRelativePosition( | |
164 view.node, bottomView.node); | |
165 currentSection.dataSourceView.detachSubview(view.source); | |
166 detachedView = view; | |
167 | |
168 FxUtil.setPosition(view.node, newPosition); | |
169 bottomView.addChild(view); | |
170 view.addClass('sel'); | |
171 currentSection.storyMode = true; | |
172 | |
173 // Create the new story view. | |
174 storyView = new StoryContentView(swarm, item); | |
175 window.setTimeout(() { | |
176 _animateDataSourceToMinimized(); | |
177 | |
178 sliderMenu.addClass('hidden'); | |
179 // Make the fancy sliding into the window animation. | |
180 window.setTimeout(() { | |
181 storyView.addClass('hidden-story'); | |
182 addChild(storyView); | |
183 window.setTimeout(() { | |
184 storyView.removeClass('hidden-story'); | |
185 }, 0); | |
186 headerView.endTransitionToStoryView(); | |
187 }, 0); | |
188 }, 0); | |
189 } | |
190 } | |
191 | |
192 void _refreshMaximized() { | |
193 if (swarm.state.storyMaximized.value) { | |
194 _animateDataSourceToMaximized(); | |
195 } else { | |
196 _animateDataSourceToMinimized(); | |
197 } | |
198 } | |
199 | |
200 void _animateDataSourceToMaximized() { | |
201 FxUtil.setWebkitTransform(topView.node, 0, -HeaderView.HEIGHT); | |
202 if (detachedView != null) { | |
203 FxUtil.setWebkitTransform(detachedView.node, 0, | |
204 -DataSourceView.TAB_ONLY_HEIGHT); | |
205 } | |
206 } | |
207 | |
208 void _animateDataSourceToMinimized() { | |
209 if (detachedView != null) { | |
210 FxUtil.setWebkitTransform(detachedView.node, 0, 0); | |
211 FxUtil.setWebkitTransform(topView.node, 0, 0); | |
212 } | |
213 } | |
214 | |
215 /** | |
216 * Called when the animation to switch to a section has completed. | |
217 */ | |
218 void _onSectionTransitionEnded(SectionView selectedView) { | |
219 // Show the section and hide the others. | |
220 for (SectionView view in sections.childViews) { | |
221 if (view == selectedView) { | |
222 // Always refresh the sources in case they've changed. | |
223 view.showSources(); | |
224 } else { | |
225 // Only show the current view for performance. | |
226 view.hideSources(); | |
227 } | |
228 } | |
229 } | |
230 | |
231 /** | |
232 * Called when the user chooses a section on the SliderMenu. Hides | |
233 * all views except the one they want to see. | |
234 */ | |
235 void _onSectionSelected(String sectionTitle) { | |
236 final section = swarm.sections.findSection(sectionTitle); | |
237 // Find the view for this section. | |
238 for (final view in sections.childViews) { | |
239 if (view.section == section) { | |
240 // Have the conveyor show it. | |
241 sections.selectView(view); | |
242 break; | |
243 } | |
244 } | |
245 } | |
246 | |
247 /** | |
248 * Create SectionViews for each Section in the app and add them to the | |
249 * conveyor. Note that the SectionViews won't actually populate or load data | |
250 * sources until they are shown in response to [:_onSectionSelected():]. | |
251 */ | |
252 void _createSectionViews() { | |
253 for (final section in swarm.sections) { | |
254 final viewFactory = new DataSourceViewFactory(swarm); | |
255 final sectionView = new SectionView(swarm, section, viewFactory); | |
256 | |
257 // TODO(rnystrom): Hack temp. Access node to make sure SectionView has | |
258 // rendered and created scroller. This can go away when event registration | |
259 // is being deferred. | |
260 sectionView.node; | |
261 | |
262 sections.addChild(sectionView); | |
263 } | |
264 addChild(sections); | |
265 } | |
266 | |
267 /** | |
268 * Controls the logic of how to respond to keypresses and then update the | |
269 * UI accordingly. | |
270 */ | |
271 void processKeyEvent(KeyboardEvent e) { | |
272 int code = e.keyCode; | |
273 if (swarm.state.inMainView) { | |
274 // Option 1: We're in the Main Grid mode. | |
275 if (!swarm.state.hasArticleSelected) { | |
276 // Then a key has been pressed. Select the first item in the | |
277 // top left corner. | |
278 swarm.state.goToFirstArticleInSection(); | |
279 } else if (rightKeyPresses.contains(code)) { | |
280 // Store original state that is needed if we need to move | |
281 // to the next section. | |
282 swarm.state.goToNextFeed(); | |
283 } else if (leftKeyPresses.contains(code)) { | |
284 // Store original state that is needed if we need to move | |
285 // to the next section. | |
286 swarm.state.goToPreviousFeed(); | |
287 } else if (downKeyPresses.contains(code)) { | |
288 swarm.state.goToNextSelectedArticle(); | |
289 } else if (upKeyPresses.contains(code)) { | |
290 swarm.state.goToPreviousSelectedArticle(); | |
291 } else if (openKeyPresses.contains(code)) { | |
292 // View a story in the larger Story View. | |
293 swarm.state.selectStoryAsCurrent(); | |
294 } else if (nextPageKeyPresses.contains(code)) { | |
295 swarm.state.goToNextSection(sliderMenu); | |
296 } else if (previousPageKeyPresses.contains(code)) { | |
297 swarm.state.goToPreviousSection(sliderMenu); | |
298 } | |
299 } else { | |
300 // Option 2: We're in Story Mode. In this mode, the user can move up | |
301 // and down through stories, which automatically loads the next story. | |
302 if (downKeyPresses.contains(code)) { | |
303 swarm.state.goToNextArticle(); | |
304 } else if (upKeyPresses.contains(code)) { | |
305 swarm.state.goToPreviousArticle(); | |
306 } else if (backKeyPresses.contains(code)) { | |
307 // Move back to the main grid view. | |
308 swarm.state.clearCurrentArticle(); | |
309 } | |
310 } | |
311 } | |
312 } | |
313 | |
314 /** Transitions the app back to the main screen. */ | |
315 void _backToMain(SwarmState state) { | |
316 if (state.currentArticle.value != null) { | |
317 state.clearCurrentArticle(); | |
318 state.storyTextMode.value = true; | |
319 state.pushToHistory(); | |
320 } | |
321 } | |
322 | |
323 /** A back button that sends the user back to the front page. */ | |
324 class SwarmBackButton extends View { | |
325 Swarm swarm; | |
326 | |
327 SwarmBackButton(this.swarm) : super(); | |
328 | |
329 Element render() => new Element.html('<div class="back-arrow button"></div>'); | |
330 | |
331 void afterRender(Element node) { | |
332 addOnClick((e) { _backToMain(swarm.state); }); | |
333 } | |
334 } | |
335 | |
336 /** Top view constaining the title and standard buttons. */ | |
337 class HeaderView extends CompositeView { | |
338 // TODO(jacobr): make this value be coupled with the CSS file. | |
339 static final HEIGHT = 80; | |
340 Swarm swarm; | |
341 | |
342 View _title; | |
343 View _infoButton; | |
344 View _configButton; | |
345 View _refreshButton; | |
346 SwarmBackButton _backButton; | |
347 View _infoDialog; | |
348 View _configDialog; | |
349 | |
350 // For (text/web) article view controls | |
351 View _webBackButton; | |
352 View _webForwardButton; | |
353 View _newWindowButton; | |
354 | |
355 HeaderView(this.swarm) : super('header-view') { | |
356 _backButton = addChild(new SwarmBackButton(swarm)); | |
357 _title = addChild(View.div('app-title', 'Swarm')); | |
358 _configButton = addChild(View.div('config button')); | |
359 _refreshButton = addChild(View.div('refresh button')); | |
360 _infoButton = addChild(View.div('info-button button')); | |
361 | |
362 // TODO(rnystrom): No more web/text mode (it's just text) so get rid of | |
363 // these. | |
364 _webBackButton = addChild(new WebBackButton()); | |
365 _webForwardButton = addChild(new WebForwardButton()); | |
366 _newWindowButton = addChild(View.div('new-window-button button')); | |
367 } | |
368 | |
369 void afterRender(Element node) { | |
370 // Respond to changes to whether the story is being shown as text or web. | |
371 attachWatch(swarm.state.storyTextMode, (e) { refreshWebStoryButtons(); }); | |
372 | |
373 _title.addOnClick((e) { _backToMain(swarm.state); }); | |
374 | |
375 // Wire up the events. | |
376 _configButton.addOnClick((e) { | |
377 // Bring up the config dialog. | |
378 if (this._configDialog == null) { | |
379 // TODO(terry): Cleanup, HeaderView shouldn't be tangled with main view. | |
380 this._configDialog = new ConfigHintDialog(swarm.frontView, () { | |
381 swarm.frontView.removeChild(this._configDialog); | |
382 this._configDialog = null; | |
383 | |
384 // TODO: Need to push these to the server on a per-user basis. | |
385 // Update the storage now. | |
386 swarm.sections.refresh(); | |
387 }); | |
388 | |
389 swarm.frontView.addChild(this._configDialog); | |
390 } | |
391 // TODO(jimhug): Graceful redirection to reader. | |
392 }); | |
393 | |
394 // On click of the refresh button, refresh the swarm. | |
395 _refreshButton.addOnClick(EventBatch.wrap((e) { | |
396 swarm.refresh(); | |
397 })); | |
398 | |
399 // On click of the info button, show Dart info page in new window/tab. | |
400 _infoButton.addOnClick((e) { | |
401 // Bring up the config dialog. | |
402 if (this._infoDialog == null) { | |
403 // TODO(terry): Cleanup, HeaderView shouldn't be tangled with main view. | |
404 this._infoDialog = new HelpDialog(swarm.frontView, () { | |
405 swarm.frontView.removeChild(this._infoDialog); | |
406 this._infoDialog = null; | |
407 | |
408 swarm.sections.refresh(); | |
409 }); | |
410 | |
411 swarm.frontView.addChild(this._infoDialog); | |
412 } | |
413 }); | |
414 | |
415 // On click of the new window button, show web article in new window/tab. | |
416 _newWindowButton.addOnClick((e) { | |
417 String currentArticleSrcUrl = swarm.state.currentArticle.value.srcUrl; | |
418 window.open(currentArticleSrcUrl, '_blank'); | |
419 }); | |
420 | |
421 startTransitionToMainView(); | |
422 } | |
423 | |
424 | |
425 /** | |
426 * Refreshes whether or not the buttons specific to the display of a story in | |
427 * the web perspective are visible. | |
428 */ | |
429 void refreshWebStoryButtons() { | |
430 bool webButtonsHidden = true; | |
431 | |
432 if (swarm.state.currentArticle.value != null) { | |
433 // Set if web buttons are hidden | |
434 webButtonsHidden = swarm.state.storyTextMode.value; | |
435 } | |
436 | |
437 _webBackButton.hidden = webButtonsHidden; | |
438 _webForwardButton.hidden = webButtonsHidden; | |
439 _newWindowButton.hidden = webButtonsHidden; | |
440 } | |
441 | |
442 void startTransitionToMainView() { | |
443 _title.removeClass('in-story'); | |
444 _backButton.removeClass('in-story'); | |
445 | |
446 _configButton.removeClass('in-story'); | |
447 _refreshButton.removeClass('in-story'); | |
448 _infoButton.removeClass('in-story'); | |
449 | |
450 refreshWebStoryButtons(); | |
451 } | |
452 | |
453 void endTransitionToStoryView() { | |
454 _title.addClass('in-story'); | |
455 _backButton.addClass('in-story'); | |
456 | |
457 _configButton.addClass('in-story'); | |
458 _refreshButton.addClass('in-story'); | |
459 _infoButton.addClass('in-story'); | |
460 } | |
461 } | |
462 | |
463 | |
464 /** A back button for the web view of a story that is equivalent to clicking | |
465 * "back" in the browser. */ | |
466 // TODO(rnystrom): We have nearly identical versions of this littered through | |
467 // the sample apps. Should consolidate into one. | |
468 class WebBackButton extends View { | |
469 WebBackButton() : super(); | |
470 | |
471 Element render() { | |
472 return new Element.html('<div class="web-back-button button"></div>'); | |
473 } | |
474 | |
475 void afterRender(Element node) { | |
476 addOnClick((e) { back(); }); | |
477 } | |
478 | |
479 /** Equivalent to [window.history.back] */ | |
480 static void back() { | |
481 window.history.back(); | |
482 } | |
483 } | |
484 | |
485 /** A back button for the web view of a story that is equivalent to clicking | |
486 * "forward" in the browser. */ | |
487 // TODO(rnystrom): We have nearly identical versions of this littered through | |
488 // the sample apps. Should consolidate into one. | |
489 class WebForwardButton extends View { | |
490 WebForwardButton() : super(); | |
491 | |
492 Element render() { | |
493 return new Element.html('<div class="web-forward-button button"></div>'); | |
494 } | |
495 | |
496 void afterRender(Element node) { | |
497 addOnClick((e) { forward(); }); | |
498 } | |
499 | |
500 /** Equivalent to [window.history.forward] */ | |
501 static void forward() { | |
502 window.history.forward(); | |
503 } | |
504 } | |
505 | |
506 /** | |
507 * A factory that creates a view for data sources. | |
508 */ | |
509 class DataSourceViewFactory implements ViewFactory<Feed> { | |
510 Swarm swarm; | |
511 | |
512 DataSourceViewFactory(this.swarm) {} | |
513 | |
514 View newView(Feed data) => new DataSourceView(data, swarm); | |
515 | |
516 int get width() => ArticleViewLayout.getSingleton().width; | |
517 int get height() => null; // Width for this view isn't known. | |
518 } | |
519 | |
520 | |
521 /** | |
522 * A view for the items from a single data source. | |
523 * Shows a title and a list of items. | |
524 */ | |
525 class DataSourceView extends CompositeView { | |
526 // TODO(jacobr): make this value be coupled with the CSS file. | |
527 static final TAB_ONLY_HEIGHT = 34; | |
528 | |
529 final Feed source; | |
530 VariableSizeListView<Article> itemsView; | |
531 | |
532 DataSourceView(this.source, Swarm swarm) : super('query') { | |
533 | |
534 // TODO(jacobr): make the title a view or decide it is sane for a subclass | |
535 // of component view to manually add some DOM cruft. | |
536 node.nodes.add(new Element.html( | |
537 '<h2>${source.title}</h2>')); | |
538 | |
539 // TODO(jacobr): use named arguments when available. | |
540 itemsView = addChild(new VariableSizeListView<Article>( | |
541 source.articles, | |
542 new ArticleViewFactory(swarm), | |
543 true, /* scrollable */ | |
544 true, /* vertical */ | |
545 swarm.state.currentArticle, /* selectedItem */ | |
546 !Device.supportsTouch /* snapToArticles */, | |
547 false /* paginate */, | |
548 true /* removeClippedViews */, | |
549 !Device.supportsTouch /* showScrollbar */)); | |
550 itemsView.addClass('story-section'); | |
551 | |
552 node.nodes.add(new Element.html('<div class="query-name-shadow"></div>')); | |
553 | |
554 // Clicking the view (i.e. its title area) unmaximizes to show the entire | |
555 // view. | |
556 node.on.mouseDown.add((e) { | |
557 swarm.state.storyMaximized.value = false; | |
558 }, false); | |
559 } | |
560 } | |
561 | |
562 /** A button that toggles between states. */ | |
563 class ToggleButton extends View { | |
564 EventListeners onChanged; | |
565 List<String> states; | |
566 | |
567 ToggleButton(this.states) | |
568 : super(), | |
569 onChanged = new EventListeners(); | |
570 | |
571 Element render() => new Element.tag('button'); | |
572 | |
573 void afterRender(Element node) { | |
574 state = states[0]; | |
575 node.on.click.add((event) { toggle(); }, false); | |
576 } | |
577 | |
578 String get state() { | |
579 final currentState = node.innerHTML; | |
580 assert(states.indexOf(currentState, 0) >= 0); | |
581 return currentState; | |
582 } | |
583 | |
584 void set state(String state) { | |
585 assert(states.indexOf(state, 0) >= 0); | |
586 node.innerHTML = state; | |
587 onChanged.fire(null); | |
588 } | |
589 | |
590 void toggle() { | |
591 final oldState = state; | |
592 int index = states.indexOf(oldState, 0); | |
593 index = (index + 1) % states.length; | |
594 state = states[index]; | |
595 } | |
596 } | |
597 | |
598 /** | |
599 * A factory that creates a view for generic items. | |
600 */ | |
601 class ArticleViewFactory implements VariableSizeViewFactory<Article> { | |
602 Swarm swarm; | |
603 | |
604 ArticleViewLayout layout; | |
605 ArticleViewFactory(this.swarm) | |
606 : layout = ArticleViewLayout.getSingleton(); | |
607 | |
608 View newView(Article item) => new ArticleView(item, swarm, layout); | |
609 | |
610 int getWidth(Article item) => layout.width; | |
611 int getHeight(Article item) => layout.computeHeight(item); | |
612 } | |
613 | |
614 class ArticleViewMetrics { | |
615 final int height; | |
616 final int titleLines; | |
617 final int bodyLines; | |
618 | |
619 const ArticleViewMetrics(this.height, this.titleLines, this.bodyLines); | |
620 } | |
621 | |
622 class ArticleViewLayout { | |
623 // TODO(terry): clean this up once we have a framework for sharing constants | |
624 // between JS and CSS. See bug #5405307. | |
625 static final IPAD_WIDTH = 257; | |
626 static final DESKTOP_WIDTH = 297; | |
627 static final CHROME_OS_WIDTH = 317; | |
628 static final TITLE_MARGIN_LEFT = 257 - 150; | |
629 static final BODY_MARGIN_LEFT = 257 - 221; | |
630 static final LINE_HEIGHT = 18; | |
631 static final TITLE_FONT = 'bold 13px arial,sans-serif'; | |
632 static final BODY_FONT = '13px arial,sans-serif'; | |
633 static final TOTAL_MARGIN = 16 * 2 + 70; | |
634 static final MIN_TITLE_HEIGHT = 36; | |
635 static final MAX_TITLE_LINES = 2; | |
636 static final MAX_BODY_LINES = 4; | |
637 | |
638 MeasureText measureTitleText; | |
639 MeasureText measureBodyText; | |
640 | |
641 int width; | |
642 static ArticleViewLayout _singleton; | |
643 ArticleViewLayout() : | |
644 measureBodyText = new MeasureText(BODY_FONT), | |
645 measureTitleText = new MeasureText(TITLE_FONT) { | |
646 num screenWidth = window.screen.width; | |
647 width = DESKTOP_WIDTH; | |
648 } | |
649 | |
650 static ArticleViewLayout getSingleton() { | |
651 if (_singleton == null) { | |
652 _singleton = new ArticleViewLayout(); | |
653 } | |
654 return _singleton; | |
655 } | |
656 | |
657 int computeHeight(Article item) { | |
658 if (item == null) { | |
659 // TODO(jacobr): find out why this is happening.. | |
660 print('Null item encountered.'); | |
661 return 0; | |
662 } | |
663 | |
664 return computeLayout(item, null, null).height; | |
665 } | |
666 | |
667 /** | |
668 * titleContainer and snippetContainer may be null in which case the size is | |
669 * computed but no actual layout is performed. | |
670 */ | |
671 ArticleViewMetrics computeLayout(Article item, | |
672 StringBuffer titleBuffer, | |
673 StringBuffer snippetBuffer) { | |
674 int titleWidth = width - BODY_MARGIN_LEFT; | |
675 | |
676 if (item.hasThumbnail) { | |
677 titleWidth = width - TITLE_MARGIN_LEFT; | |
678 } | |
679 | |
680 final titleLines = measureTitleText.addLineBrokenText(titleBuffer, | |
681 item.title, titleWidth, MAX_TITLE_LINES); | |
682 final bodyLines = measureBodyText.addLineBrokenText(snippetBuffer, | |
683 item.textBody, width - BODY_MARGIN_LEFT, MAX_BODY_LINES); | |
684 | |
685 int height = bodyLines * LINE_HEIGHT + TOTAL_MARGIN; | |
686 | |
687 if (bodyLines == 0) { | |
688 height = 92; | |
689 } | |
690 | |
691 return new ArticleViewMetrics(height, titleLines, bodyLines); | |
692 } | |
693 } | |
694 | |
695 /** | |
696 * A view for a generic item. | |
697 */ | |
698 class ArticleView extends View { | |
699 // Set to false to make inspecting the HTML more pleasant... | |
700 static final SAVE_IMAGES = false; | |
701 | |
702 final Article item; | |
703 final Swarm swarm; | |
704 final ArticleViewLayout articleLayout; | |
705 | |
706 ArticleView(this.item, this.swarm, this.articleLayout) : super(); | |
707 | |
708 Element render() { | |
709 Element node; | |
710 | |
711 final byline = item.author.length > 0 ? item.author : item.dataSource.title; | |
712 final date = DateUtils.toRecentTimeString(item.date); | |
713 | |
714 String storyClass = 'story no-thumb'; | |
715 String thumbnail = ''; | |
716 | |
717 if (item.hasThumbnail) { | |
718 storyClass = 'story'; | |
719 thumbnail = '<img src="${item.thumbUrl}"></img>'; | |
720 } | |
721 | |
722 final title = new StringBuffer(); | |
723 final snippet = new StringBuffer(); | |
724 | |
725 // Note: also populates title and snippet elements. | |
726 final metrics = articleLayout.computeLayout(item, title, snippet); | |
727 | |
728 node = new Element.html(''' | |
729 <div class="$storyClass"> | |
730 $thumbnail | |
731 <div class="title">$title</div> | |
732 <div class="byline">$byline</div> | |
733 <div class="dateline">$date</div> | |
734 <div class="snippet">$snippet</div> | |
735 </div>'''); | |
736 | |
737 // Remove the snippet entirely if it's empty. This keeps it from taking up | |
738 // space and pushing the padding down. | |
739 if ((item.textBody == null) || (item.textBody.trim() == '')) { | |
740 node.query('.snippet').remove(); | |
741 } | |
742 | |
743 return node; | |
744 } | |
745 | |
746 void afterRender(Element node) { | |
747 | |
748 // Select this view's item. | |
749 addOnClick((e) { | |
750 // Mark the item as read, so it shows as read in other views | |
751 item.unread.value = false; | |
752 | |
753 final oldArticle = swarm.state.currentArticle.value; | |
754 swarm.state.currentArticle.value = item; | |
755 swarm.state.storyTextMode.value = true; | |
756 if (oldArticle == null) { | |
757 swarm.state.pushToHistory(); | |
758 } | |
759 }); | |
760 | |
761 watch(swarm.state.currentArticle, (e) { | |
762 if (!swarm.state.inMainView) { | |
763 swarm.state.markCurrentAsRead(); | |
764 } | |
765 _refreshSelected(swarm.state.currentArticle); | |
766 //TODO(efortuna): add in history stuff while reading articles? | |
767 }); | |
768 | |
769 watch(swarm.state.selectedArticle, (e) { | |
770 _refreshSelected(swarm.state.selectedArticle); | |
771 _updateViewForSelectedArticle(); | |
772 }); | |
773 | |
774 watch(item.unread, (e) { | |
775 // TODO(rnystrom): Would be nice to do: | |
776 // node.classes.set('story-unread', item.unread.value) | |
777 if (item.unread.value) { | |
778 node.classes.add('story-unread'); | |
779 } else { | |
780 node.classes.remove('story-unread'); | |
781 } | |
782 }); | |
783 } | |
784 | |
785 /** | |
786 * Notify the view to jump to a different area if we are selecting an | |
787 * article that is currently outside of the visible area. | |
788 */ | |
789 void _updateViewForSelectedArticle() { | |
790 Article selArticle = swarm.state.selectedArticle.value; | |
791 if (swarm.state.hasArticleSelected) { | |
792 // Ensure that the selected article is visible in the view. | |
793 if (!swarm.state.inMainView) { | |
794 // Story View. | |
795 swarm.frontView.detachedView.itemsView.showView(selArticle); | |
796 } else { | |
797 if(swarm.frontView.currentSection.inCurrentView(selArticle)) { | |
798 // Scroll horizontally if needed. | |
799 swarm.frontView.currentSection.dataSourceView.showView( | |
800 selArticle.dataSource); | |
801 DataSourceView dataView = swarm.frontView.currentSection | |
802 .findView(selArticle.dataSource); | |
803 if(dataView != null) { | |
804 dataView.itemsView.showView(selArticle); | |
805 } | |
806 } | |
807 } | |
808 } | |
809 } | |
810 | |
811 String getDataUriForImage(final img) { | |
812 // TODO(hiltonc,jimhug) eval perf of this vs. reusing one canvas element | |
813 final canvas = new Element.html( | |
814 '<canvas width="${img.width}" height="${img.height}"></canvas>'); | |
815 | |
816 final ctx = canvas.getContext("2d"); | |
817 ctx.drawImage(img, 0, 0, img.width, img.height); | |
818 | |
819 return canvas.toDataURL("image/png"); | |
820 } | |
821 | |
822 /** | |
823 * Update this view's selected appearance based on the currently selected | |
824 * Article. | |
825 */ | |
826 void _refreshSelected(curItem) { | |
827 if (curItem.value == item) { | |
828 addClass('sel'); | |
829 } else { | |
830 removeClass('sel'); | |
831 } | |
832 } | |
833 | |
834 void _saveToStorage(String thumbUrl, ImageElement img) { | |
835 // TODO(jimhug): Reimplement caching of images. | |
836 } | |
837 } | |
838 | |
839 /** | |
840 * An internal view of a story as text. In other words, the article is shown | |
841 * in-place as opposed to as an embedded web-page. | |
842 */ | |
843 class StoryContentView extends View { | |
844 final Swarm swarm; | |
845 final Article item; | |
846 | |
847 View _pagedStory; | |
848 | |
849 StoryContentView(this.swarm, this.item) : super(); | |
850 | |
851 get childViews() => [_pagedStory]; | |
852 | |
853 Element render() { | |
854 final storyContent = new Element.html( | |
855 '<div class="story-content">${item.htmlBody}</div>'); | |
856 for (Element element in storyContent.queryAll( | |
857 "iframe, script, style, object, embed, frameset, frame")) { | |
858 element.remove(); | |
859 } | |
860 _pagedStory = new PagedContentView(new View.fromNode(storyContent)); | |
861 | |
862 // Modify all links to open in new windows.... | |
863 // TODO(jacobr): would it be better to add an event listener on click that | |
864 // intercepts these instead? | |
865 for (final anchor in storyContent.queryAll('a')) { | |
866 anchor.target = '_blank'; | |
867 } | |
868 | |
869 final date = DateUtils.toRecentTimeString(item.date); | |
870 final container = new Element.html(''' | |
871 <div class="story-view"> | |
872 <div class="story-text-view"> | |
873 <div class="story-header"> | |
874 <a class="story-title" href="${item.srcUrl}" target="_blank"> | |
875 ${item.title}</a> | |
876 <div class="story-byline"> | |
877 ${item.author} - ${item.dataSource.title} | |
878 </div> | |
879 <div class="story-dateline">$date</div> | |
880 </div> | |
881 <div class="paged-story"></div> | |
882 <div class="spacer"></div> | |
883 </div> | |
884 </div>'''); | |
885 | |
886 container.query('.paged-story').replaceWith(_pagedStory.node); | |
887 | |
888 return container; | |
889 } | |
890 } | |
891 | |
892 class SectionView extends CompositeView { | |
893 final Section section; | |
894 final Swarm swarm; | |
895 final DataSourceViewFactory _viewFactory; | |
896 final View loadingText; | |
897 ListView<Feed> dataSourceView; | |
898 PageNumberView pageNumberView; | |
899 final PageState pageState; | |
900 | |
901 SectionView(this.swarm, this.section, this._viewFactory) | |
902 : super('section-view'), | |
903 loadingText = new View.html('<div class="loading-section"></div>'), | |
904 pageState = new PageState() { | |
905 addChild(loadingText); | |
906 } | |
907 | |
908 /** | |
909 * Hides the loading text, reloads the data sources, and shows them. | |
910 */ | |
911 void showSources() { | |
912 loadingText.node.style.display = 'none'; | |
913 | |
914 // Lazy initialize the data source view. | |
915 if (dataSourceView == null) { | |
916 // TODO(jacobr): use named arguments when available. | |
917 dataSourceView = new ListView<Feed>( | |
918 section.feeds, _viewFactory, | |
919 true /* scrollable */, | |
920 false /* vertical */, | |
921 null /* selectedItem */, | |
922 true /* snapToItems */, | |
923 true /* paginate */, | |
924 true /* removeClippedViews */, | |
925 false, /* showScrollbar */ | |
926 pageState); | |
927 dataSourceView.addClass("data-source-view"); | |
928 addChild(dataSourceView); | |
929 | |
930 pageNumberView = addChild(new PageNumberView(pageState)); | |
931 | |
932 node.style.opacity = '1'; | |
933 } else { | |
934 addChild(dataSourceView); | |
935 addChild(pageNumberView); | |
936 node.style.opacity = '1'; | |
937 } | |
938 | |
939 // TODO(jacobr): get rid of this call to reconfigure when it is not needed. | |
940 dataSourceView.scroller.reconfigure(() {}); | |
941 } | |
942 | |
943 /** | |
944 * Hides the data sources and shows the loading text. | |
945 */ | |
946 void hideSources() { | |
947 if (dataSourceView != null) { | |
948 node.style.opacity = '0.6'; | |
949 removeChild(dataSourceView); | |
950 removeChild(pageNumberView); | |
951 } | |
952 | |
953 loadingText.node.style.display = 'block'; | |
954 } | |
955 | |
956 set storyMode(bool inStoryMode) { | |
957 if (inStoryMode) { | |
958 addClass('hide-all-queries'); | |
959 } else { | |
960 removeClass('hide-all-queries'); | |
961 } | |
962 } | |
963 | |
964 /** | |
965 * Find the [DataSourceView] in this SectionView that's displaying the given | |
966 * [Feed]. | |
967 */ | |
968 DataSourceView findView(Feed dataSource) { | |
969 return dataSourceView.getSubview(dataSourceView.findIndex(dataSource)); | |
970 } | |
971 | |
972 bool inCurrentView(Article article) { | |
973 return dataSourceView.findIndex(article.dataSource) != null; | |
974 } | |
975 } | |
OLD | NEW |