| 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 |