| // Copyright (c) 2011, the Dart project authors. Please see the AUTHORS file |
| // for details. All rights reserved. Use of this source code is governed by a |
| // BSD-style license that can be found in the LICENSE file. |
| |
| // @dart = 2.9 |
| |
| part of swarmlib; |
| |
| // TODO(jacobr): there is a lot of dead code in this class. Checking is as is |
| // and then doing a large pass to remove functionality that doesn't make sense |
| // given the UI layout. |
| |
| /** |
| * Front page of Swarm. |
| */ |
| // TODO(jacobr): this code now needs a large refactoring. |
| // Suggested refactorings: |
| // Move animation specific code into helper classes. |
| class FrontView extends CompositeView { |
| final Swarm swarm; |
| |
| /** View containing all UI anchored to the top of the page. */ |
| CompositeView topView; |
| /** View containing all UI anchored to the left side of the page. */ |
| CompositeView bottomView; |
| HeaderView headerView; |
| SliderMenu sliderMenu; |
| |
| /** |
| * When the user is viewing a story, the data source for that story is |
| * detached from the section and shown at the bottom of the screen. This keeps |
| * track of that so we can restore it later. |
| */ |
| DataSourceView detachedView; |
| |
| /** |
| * Map from section title to the View that shows this section. This |
| * is populated lazily. |
| */ |
| StoryContentView storyView; |
| bool nextPrevShown; |
| |
| ConveyorView sections; |
| |
| /** |
| * The set of keys that produce a given behavior (going down one story, |
| * navigating to the column to the right, etc). |
| */ |
| //TODO(jmesserly): we need a key code enumeration |
| final Set downKeyPresses; |
| final Set upKeyPresses; |
| final Set rightKeyPresses; |
| final Set leftKeyPresses; |
| final Set openKeyPresses; |
| final Set backKeyPresses; |
| final Set nextPageKeyPresses; |
| final Set previousPageKeyPresses; |
| |
| FrontView(this.swarm) |
| : downKeyPresses = new Set.from([74 /*j*/, 40 /*down*/]), |
| upKeyPresses = new Set.from([75 /*k*/, 38 /*up*/]), |
| rightKeyPresses = new Set.from([39 /*right*/, 68 /*d*/, 76 /*l*/]), |
| leftKeyPresses = new Set.from([37 /*left*/, 65 /*a*/, 72 /*h*/]), |
| openKeyPresses = new Set.from([13 /*enter*/, 79 /*o*/]), |
| backKeyPresses = new Set.from([8 /*delete*/, 27 /*escape*/]), |
| nextPageKeyPresses = new Set.from([78 /*n*/]), |
| previousPageKeyPresses = new Set.from([80 /*p*/]), |
| nextPrevShown = false, |
| super('front-view fullpage') { |
| topView = new CompositeView('top-view', false, false, false); |
| |
| headerView = new HeaderView(swarm); |
| topView.addChild(headerView); |
| |
| sliderMenu = new SliderMenu(swarm.sections.sectionTitles, (sectionTitle) { |
| swarm.state.moveToNewSection(sectionTitle); |
| _onSectionSelected(sectionTitle); |
| // Start with no articles selected. |
| swarm.state.selectedArticle.value = null; |
| }); |
| topView.addChild(sliderMenu); |
| addChild(topView); |
| |
| bottomView = new CompositeView('bottom-view', false, false, false); |
| addChild(bottomView); |
| |
| sections = new ConveyorView(); |
| sections.viewSelected = _onSectionTransitionEnded; |
| } |
| |
| SectionView get currentSection { |
| var view = sections.selectedView; |
| // TODO(jmesserly): this code works around a bug in the DartC --optimize |
| if (view == null) { |
| view = sections.childViews[0]; |
| sections.selectView(view); |
| } |
| return view; |
| } |
| |
| void afterRender(Element node) { |
| _createSectionViews(); |
| attachWatch(swarm.state.currentArticle, (e) { |
| _refreshCurrentArticle(); |
| }); |
| attachWatch(swarm.state.storyMaximized, (e) { |
| _refreshMaximized(); |
| }); |
| } |
| |
| void _refreshCurrentArticle() { |
| if (!swarm.state.inMainView) { |
| _animateToStory(swarm.state.currentArticle.value); |
| } else { |
| _animateToMainView(); |
| } |
| } |
| |
| /** |
| * Animates back from the story view to the main grid view. |
| */ |
| void _animateToMainView() { |
| sliderMenu.removeClass('hidden'); |
| storyView.addClass('hidden-story'); |
| currentSection.storyMode = false; |
| |
| headerView.startTransitionToMainView(); |
| |
| currentSection.dataSourceView |
| .reattachSubview(detachedView.source, detachedView, true); |
| |
| storyView.node.onTransitionEnd.first.then((e) { |
| currentSection.hidden = false; |
| // TODO(rnystrom): Should move this "mode" into SwarmState and have |
| // header view respond to change events itself. |
| removeChild(storyView); |
| storyView = null; |
| detachedView.removeClass('sel'); |
| detachedView = null; |
| }); |
| } |
| |
| void _animateToStory(Article item) { |
| final source = item.dataSource; |
| |
| if (detachedView != null && detachedView.source != source) { |
| // Ignore spurious item selection clicks that occur while a data source |
| // is already selected. These are likely clicks that occur while an |
| // animation is in progress. |
| return; |
| } |
| |
| if (storyView != null) { |
| // Remove the old story. This happens if we're already in the Story View |
| // and the user has clicked to see a new story. |
| removeChild(storyView); |
| |
| // Create the new story view and place in the frame. |
| storyView = addChild(new StoryContentView(swarm, item)); |
| } else { |
| // We are animating from the main view to the story view. |
| // TODO(jmesserly): make this code better |
| final view = currentSection.findView(source); |
| |
| final newPosition = |
| FxUtil.computeRelativePosition(view.node, bottomView.node); |
| currentSection.dataSourceView.detachSubview(view.source); |
| detachedView = view; |
| |
| FxUtil.setPosition(view.node, newPosition); |
| bottomView.addChild(view); |
| view.addClass('sel'); |
| currentSection.storyMode = true; |
| |
| // Create the new story view. |
| storyView = new StoryContentView(swarm, item); |
| new Timer(const Duration(milliseconds: 0), () { |
| _animateDataSourceToMinimized(); |
| |
| sliderMenu.addClass('hidden'); |
| // Make the fancy sliding into the window animation. |
| new Timer(const Duration(milliseconds: 0), () { |
| storyView.addClass('hidden-story'); |
| addChild(storyView); |
| new Timer(const Duration(milliseconds: 0), () { |
| storyView.removeClass('hidden-story'); |
| }); |
| headerView.endTransitionToStoryView(); |
| }); |
| }); |
| } |
| } |
| |
| void _refreshMaximized() { |
| if (swarm.state.storyMaximized.value) { |
| _animateDataSourceToMaximized(); |
| } else { |
| _animateDataSourceToMinimized(); |
| } |
| } |
| |
| void _animateDataSourceToMaximized() { |
| FxUtil.setWebkitTransform(topView.node, 0, -HeaderView.HEIGHT); |
| if (detachedView != null) { |
| FxUtil.setWebkitTransform( |
| detachedView.node, 0, -DataSourceView.TAB_ONLY_HEIGHT); |
| } |
| } |
| |
| void _animateDataSourceToMinimized() { |
| if (detachedView != null) { |
| FxUtil.setWebkitTransform(detachedView.node, 0, 0); |
| FxUtil.setWebkitTransform(topView.node, 0, 0); |
| } |
| } |
| |
| /** |
| * Called when the animation to switch to a section has completed. |
| */ |
| void _onSectionTransitionEnded(SectionView selectedView) { |
| // Show the section and hide the others. |
| for (SectionView view in sections.childViews) { |
| if (view == selectedView) { |
| // Always refresh the sources in case they've changed. |
| view.showSources(); |
| } else { |
| // Only show the current view for performance. |
| view.hideSources(); |
| } |
| } |
| } |
| |
| /** |
| * Called when the user chooses a section on the SliderMenu. Hides |
| * all views except the one they want to see. |
| */ |
| void _onSectionSelected(String sectionTitle) { |
| final section = swarm.sections.findSection(sectionTitle); |
| // Find the view for this section. |
| for (SectionView view in sections.childViews) { |
| if (view.section == section) { |
| // Have the conveyor show it. |
| sections.selectView(view); |
| break; |
| } |
| } |
| } |
| |
| /** |
| * Create SectionViews for each Section in the app and add them to the |
| * conveyor. Note that the SectionViews won't actually populate or load data |
| * sources until they are shown in response to [:_onSectionSelected():]. |
| */ |
| void _createSectionViews() { |
| for (final section in swarm.sections) { |
| final viewFactory = new DataSourceViewFactory(swarm); |
| final sectionView = new SectionView(swarm, section, viewFactory); |
| |
| // TODO(rnystrom): Hack temp. Access node to make sure SectionView has |
| // rendered and created scroller. This can go away when event registration |
| // is being deferred. |
| sectionView.node; |
| |
| sections.addChild(sectionView); |
| } |
| addChild(sections); |
| } |
| |
| /** |
| * Controls the logic of how to respond to keypresses and then update the |
| * UI accordingly. |
| */ |
| void processKeyEvent(KeyboardEvent e) { |
| int code = e.keyCode; |
| if (swarm.state.inMainView) { |
| // Option 1: We're in the Main Grid mode. |
| if (!swarm.state.hasArticleSelected) { |
| // Then a key has been pressed. Select the first item in the |
| // top left corner. |
| swarm.state.goToFirstArticleInSection(); |
| } else if (rightKeyPresses.contains(code)) { |
| // Store original state that is needed if we need to move |
| // to the next section. |
| swarm.state.goToNextFeed(); |
| } else if (leftKeyPresses.contains(code)) { |
| // Store original state that is needed if we need to move |
| // to the next section. |
| swarm.state.goToPreviousFeed(); |
| } else if (downKeyPresses.contains(code)) { |
| swarm.state.goToNextSelectedArticle(); |
| } else if (upKeyPresses.contains(code)) { |
| swarm.state.goToPreviousSelectedArticle(); |
| } else if (openKeyPresses.contains(code)) { |
| // View a story in the larger Story View. |
| swarm.state.selectStoryAsCurrent(); |
| } else if (nextPageKeyPresses.contains(code)) { |
| swarm.state.goToNextSection(sliderMenu); |
| } else if (previousPageKeyPresses.contains(code)) { |
| swarm.state.goToPreviousSection(sliderMenu); |
| } |
| } else { |
| // Option 2: We're in Story Mode. In this mode, the user can move up |
| // and down through stories, which automatically loads the next story. |
| if (downKeyPresses.contains(code)) { |
| swarm.state.goToNextArticle(); |
| } else if (upKeyPresses.contains(code)) { |
| swarm.state.goToPreviousArticle(); |
| } else if (backKeyPresses.contains(code)) { |
| // Move back to the main grid view. |
| swarm.state.clearCurrentArticle(); |
| } |
| } |
| } |
| } |
| |
| /** Transitions the app back to the main screen. */ |
| void _backToMain(SwarmState state) { |
| if (state.currentArticle.value != null) { |
| state.clearCurrentArticle(); |
| state.storyTextMode.value = true; |
| state.pushToHistory(); |
| } |
| } |
| |
| /** A back button that sends the user back to the front page. */ |
| class SwarmBackButton extends View { |
| Swarm swarm; |
| |
| SwarmBackButton(this.swarm); |
| |
| Element render() => new Element.html('<div class="back-arrow button"></div>'); |
| |
| void afterRender(Element node) { |
| addOnClick((e) { |
| _backToMain(swarm.state); |
| }); |
| } |
| } |
| |
| /** Top view constaining the title and standard buttons. */ |
| class HeaderView extends CompositeView { |
| // TODO(jacobr): make this value be coupled with the CSS file. |
| static const HEIGHT = 80; |
| Swarm swarm; |
| |
| View _title; |
| View _infoButton; |
| View _configButton; |
| View _refreshButton; |
| SwarmBackButton _backButton; |
| View _infoDialog; |
| View _configDialog; |
| |
| // For (text/web) article view controls |
| View _webBackButton; |
| View _webForwardButton; |
| View _newWindowButton; |
| |
| HeaderView(this.swarm) : super('header-view') { |
| _backButton = addChild(new SwarmBackButton(swarm)); |
| _title = addChild(View.div('app-title', 'Swarm')); |
| _configButton = addChild(View.div('config button')); |
| _refreshButton = addChild(View.div('refresh button')); |
| _infoButton = addChild(View.div('info-button button')); |
| |
| // TODO(rnystrom): No more web/text mode (it's just text) so get rid of |
| // these. |
| _webBackButton = addChild(new WebBackButton()); |
| _webForwardButton = addChild(new WebForwardButton()); |
| _newWindowButton = addChild(View.div('new-window-button button')); |
| } |
| |
| void afterRender(Element node) { |
| // Respond to changes to whether the story is being shown as text or web. |
| attachWatch(swarm.state.storyTextMode, (e) { |
| refreshWebStoryButtons(); |
| }); |
| |
| _title.addOnClick((e) { |
| _backToMain(swarm.state); |
| }); |
| |
| // Wire up the events. |
| _configButton.addOnClick((e) { |
| // Bring up the config dialog. |
| if (this._configDialog == null) { |
| // TODO(terry): Cleanup, HeaderView shouldn't be tangled with main view. |
| this._configDialog = new ConfigHintDialog(swarm.frontView, () { |
| swarm.frontView.removeChild(this._configDialog); |
| this._configDialog = null; |
| |
| // TODO: Need to push these to the server on a per-user basis. |
| // Update the storage now. |
| swarm.sections.refresh(); |
| }); |
| |
| swarm.frontView.addChild(this._configDialog); |
| } |
| // TODO(jimhug): Graceful redirection to reader. |
| }); |
| |
| // On click of the refresh button, refresh the swarm. |
| _refreshButton.addOnClick(EventBatch.wrap((e) { |
| swarm.refresh(); |
| })); |
| |
| // On click of the info button, show Dart info page in new window/tab. |
| _infoButton.addOnClick((e) { |
| // Bring up the config dialog. |
| if (this._infoDialog == null) { |
| // TODO(terry): Cleanup, HeaderView shouldn't be tangled with main view. |
| this._infoDialog = new HelpDialog(swarm.frontView, () { |
| swarm.frontView.removeChild(this._infoDialog); |
| this._infoDialog = null; |
| |
| swarm.sections.refresh(); |
| }); |
| |
| swarm.frontView.addChild(this._infoDialog); |
| } |
| }); |
| |
| // On click of the new window button, show web article in new window/tab. |
| _newWindowButton.addOnClick((e) { |
| String currentArticleSrcUrl = swarm.state.currentArticle.value.srcUrl; |
| window.open(currentArticleSrcUrl, '_blank'); |
| }); |
| |
| startTransitionToMainView(); |
| } |
| |
| /** |
| * Refreshes whether or not the buttons specific to the display of a story in |
| * the web perspective are visible. |
| */ |
| void refreshWebStoryButtons() { |
| bool webButtonsHidden = true; |
| |
| if (swarm.state.currentArticle.value != null) { |
| // Set if web buttons are hidden |
| webButtonsHidden = swarm.state.storyTextMode.value; |
| } |
| |
| _webBackButton.hidden = webButtonsHidden; |
| _webForwardButton.hidden = webButtonsHidden; |
| _newWindowButton.hidden = webButtonsHidden; |
| } |
| |
| void startTransitionToMainView() { |
| _title.removeClass('in-story'); |
| _backButton.removeClass('in-story'); |
| |
| _configButton.removeClass('in-story'); |
| _refreshButton.removeClass('in-story'); |
| _infoButton.removeClass('in-story'); |
| |
| refreshWebStoryButtons(); |
| } |
| |
| void endTransitionToStoryView() { |
| _title.addClass('in-story'); |
| _backButton.addClass('in-story'); |
| |
| _configButton.addClass('in-story'); |
| _refreshButton.addClass('in-story'); |
| _infoButton.addClass('in-story'); |
| } |
| } |
| |
| /** A back button for the web view of a story that is equivalent to clicking |
| * "back" in the browser. */ |
| // TODO(rnystrom): We have nearly identical versions of this littered through |
| // the sample apps. Should consolidate into one. |
| class WebBackButton extends View { |
| WebBackButton(); |
| |
| Element render() { |
| return new Element.html('<div class="web-back-button button"></div>'); |
| } |
| |
| void afterRender(Element node) { |
| addOnClick((e) { |
| back(); |
| }); |
| } |
| |
| /** Equivalent to [window.history.back] */ |
| static void back() { |
| window.history.back(); |
| } |
| } |
| |
| /** A back button for the web view of a story that is equivalent to clicking |
| * "forward" in the browser. */ |
| // TODO(rnystrom): We have nearly identical versions of this littered through |
| // the sample apps. Should consolidate into one. |
| class WebForwardButton extends View { |
| WebForwardButton(); |
| |
| Element render() { |
| return new Element.html('<div class="web-forward-button button"></div>'); |
| } |
| |
| void afterRender(Element node) { |
| addOnClick((e) { |
| forward(); |
| }); |
| } |
| |
| /** Equivalent to [window.history.forward] */ |
| static void forward() { |
| window.history.forward(); |
| } |
| } |
| |
| /** |
| * A factory that creates a view for data sources. |
| */ |
| class DataSourceViewFactory implements ViewFactory<Feed> { |
| Swarm swarm; |
| |
| DataSourceViewFactory(this.swarm) {} |
| |
| View newView(Feed data) => new DataSourceView(data, swarm); |
| |
| int get width => ArticleViewLayout.getSingleton().width; |
| int get height => null; // Width for this view isn't known. |
| } |
| |
| /** |
| * A view for the items from a single data source. |
| * Shows a title and a list of items. |
| */ |
| class DataSourceView extends CompositeView { |
| // TODO(jacobr): make this value be coupled with the CSS file. |
| static const TAB_ONLY_HEIGHT = 34; |
| |
| final Feed source; |
| VariableSizeListView<Article> itemsView; |
| |
| DataSourceView(this.source, Swarm swarm) : super('query') { |
| // TODO(jacobr): make the title a view or decide it is sane for a subclass |
| // of component view to manually add some DOM cruft. |
| node.nodes.add(new Element.html('<h2>${source.title}</h2>')); |
| |
| // TODO(jacobr): use named arguments when available. |
| itemsView = addChild(new VariableSizeListView<Article>( |
| source.articles, |
| new ArticleViewFactory(swarm), |
| true, |
| /* scrollable */ |
| true, |
| /* vertical */ |
| swarm.state.currentArticle, |
| /* selectedItem */ |
| !Device.supportsTouch /* snapToArticles */, |
| false /* paginate */, |
| true /* removeClippedViews */, |
| !Device.supportsTouch /* showScrollbar */)); |
| itemsView.addClass('story-section'); |
| |
| node.nodes.add(new Element.html('<div class="query-name-shadow"></div>')); |
| |
| // Clicking the view (i.e. its title area) unmaximizes to show the entire |
| // view. |
| node.onMouseDown.listen((e) { |
| swarm.state.storyMaximized.value = false; |
| }); |
| } |
| } |
| |
| /** A button that toggles between states. */ |
| class ToggleButton extends View { |
| EventListeners onChanged; |
| List<String> states; |
| |
| ToggleButton(this.states) : onChanged = new EventListeners(); |
| |
| Element render() => new Element.tag('button'); |
| |
| void afterRender(Element node) { |
| state = states[0]; |
| node.onClick.listen((event) { |
| toggle(); |
| }); |
| } |
| |
| String get state { |
| final currentState = node.innerHtml; |
| assert(states.indexOf(currentState, 0) >= 0); |
| return currentState; |
| } |
| |
| void set state(String state) { |
| assert(states.indexOf(state, 0) >= 0); |
| node.innerHtml = state; |
| onChanged.fire(null); |
| } |
| |
| void toggle() { |
| final oldState = state; |
| int index = states.indexOf(oldState, 0); |
| index = (index + 1) % states.length; |
| state = states[index]; |
| } |
| } |
| |
| /** |
| * A factory that creates a view for generic items. |
| */ |
| class ArticleViewFactory implements VariableSizeViewFactory<Article> { |
| Swarm swarm; |
| |
| ArticleViewLayout layout; |
| ArticleViewFactory(this.swarm) : layout = ArticleViewLayout.getSingleton(); |
| |
| View newView(Article item) => new ArticleView(item, swarm, layout); |
| |
| int getWidth(Article item) => layout.width; |
| int getHeight(Article item) => layout.computeHeight(item); |
| } |
| |
| class ArticleViewMetrics { |
| final int height; |
| final int titleLines; |
| final int bodyLines; |
| |
| const ArticleViewMetrics(this.height, this.titleLines, this.bodyLines); |
| } |
| |
| class ArticleViewLayout { |
| // TODO(terry): clean this up once we have a framework for sharing constants |
| // between JS and CSS. See bug #5405307. |
| static const IPAD_WIDTH = 257; |
| static const DESKTOP_WIDTH = 297; |
| static const CHROME_OS_WIDTH = 317; |
| static const TITLE_MARGIN_LEFT = 257 - 150; |
| static const BODY_MARGIN_LEFT = 257 - 221; |
| static const LINE_HEIGHT = 18; |
| static const TITLE_FONT = 'bold 13px arial,sans-serif'; |
| static const BODY_FONT = '13px arial,sans-serif'; |
| static const TOTAL_MARGIN = 16 * 2 + 70; |
| static const MIN_TITLE_HEIGHT = 36; |
| static const MAX_TITLE_LINES = 2; |
| static const MAX_BODY_LINES = 4; |
| |
| MeasureText measureTitleText; |
| MeasureText measureBodyText; |
| |
| int width; |
| static ArticleViewLayout _singleton; |
| ArticleViewLayout() |
| : measureBodyText = new MeasureText(BODY_FONT), |
| measureTitleText = new MeasureText(TITLE_FONT) { |
| num screenWidth = window.screen.width; |
| width = DESKTOP_WIDTH; |
| } |
| |
| static ArticleViewLayout getSingleton() { |
| if (_singleton == null) { |
| _singleton = new ArticleViewLayout(); |
| } |
| return _singleton; |
| } |
| |
| int computeHeight(Article item) { |
| if (item == null) { |
| // TODO(jacobr): find out why this is happening.. |
| print('Null item encountered.'); |
| return 0; |
| } |
| |
| return computeLayout(item, null, null).height; |
| } |
| |
| /** |
| * titleContainer and snippetContainer may be null in which case the size is |
| * computed but no actual layout is performed. |
| */ |
| ArticleViewMetrics computeLayout( |
| Article item, StringBuffer titleBuffer, StringBuffer snippetBuffer) { |
| int titleWidth = width - BODY_MARGIN_LEFT; |
| |
| if (item.hasThumbnail) { |
| titleWidth = width - TITLE_MARGIN_LEFT; |
| } |
| |
| final titleLines = measureTitleText.addLineBrokenText( |
| titleBuffer, item.title, titleWidth, MAX_TITLE_LINES); |
| final bodyLines = measureBodyText.addLineBrokenText( |
| snippetBuffer, item.textBody, width - BODY_MARGIN_LEFT, MAX_BODY_LINES); |
| |
| int height = bodyLines * LINE_HEIGHT + TOTAL_MARGIN; |
| |
| if (bodyLines == 0) { |
| height = 92; |
| } |
| |
| return new ArticleViewMetrics(height, titleLines, bodyLines); |
| } |
| } |
| |
| /** |
| * A view for a generic item. |
| */ |
| class ArticleView extends View { |
| // Set to false to make inspecting the HTML more pleasant... |
| static const SAVE_IMAGES = false; |
| |
| final Article item; |
| final Swarm swarm; |
| final ArticleViewLayout articleLayout; |
| |
| ArticleView(this.item, this.swarm, this.articleLayout); |
| |
| Element render() { |
| Element node; |
| |
| final byline = item.author.length > 0 ? item.author : item.dataSource.title; |
| final date = DateUtils.toRecentTimeString(item.date); |
| |
| String storyClass = 'story no-thumb'; |
| String thumbnail = ''; |
| |
| if (item.hasThumbnail) { |
| storyClass = 'story'; |
| thumbnail = '<img src="${item.thumbUrl}"></img>'; |
| } |
| |
| final title = new StringBuffer(); |
| final snippet = new StringBuffer(); |
| |
| // Note: also populates title and snippet elements. |
| final metrics = articleLayout.computeLayout(item, title, snippet); |
| |
| node = new Element.html(''' |
| <div class="$storyClass"> |
| $thumbnail |
| <div class="title">$title</div> |
| <div class="byline">$byline</div> |
| <div class="dateline">$date</div> |
| <div class="snippet">$snippet</div> |
| </div>'''); |
| |
| // Remove the snippet entirely if it's empty. This keeps it from taking up |
| // space and pushing the padding down. |
| if ((item.textBody == null) || (item.textBody.trim() == '')) { |
| node.querySelector('.snippet').remove(); |
| } |
| |
| return node; |
| } |
| |
| void afterRender(Element node) { |
| // Select this view's item. |
| addOnClick((e) { |
| // Mark the item as read, so it shows as read in other views |
| item.unread.value = false; |
| |
| final oldArticle = swarm.state.currentArticle.value; |
| swarm.state.currentArticle.value = item; |
| swarm.state.storyTextMode.value = true; |
| if (oldArticle == null) { |
| swarm.state.pushToHistory(); |
| } |
| }); |
| |
| watch(swarm.state.currentArticle, (e) { |
| if (!swarm.state.inMainView) { |
| swarm.state.markCurrentAsRead(); |
| } |
| _refreshSelected(swarm.state.currentArticle); |
| //TODO(efortuna): add in history stuff while reading articles? |
| }); |
| |
| watch(swarm.state.selectedArticle, (e) { |
| _refreshSelected(swarm.state.selectedArticle); |
| _updateViewForSelectedArticle(); |
| }); |
| |
| watch(item.unread, (e) { |
| // TODO(rnystrom): Would be nice to do: |
| // node.classes.set('story-unread', item.unread.value) |
| if (item.unread.value) { |
| node.classes.add('story-unread'); |
| } else { |
| node.classes.remove('story-unread'); |
| } |
| }); |
| } |
| |
| /** |
| * Notify the view to jump to a different area if we are selecting an |
| * article that is currently outside of the visible area. |
| */ |
| void _updateViewForSelectedArticle() { |
| Article selArticle = swarm.state.selectedArticle.value; |
| if (swarm.state.hasArticleSelected) { |
| // Ensure that the selected article is visible in the view. |
| if (!swarm.state.inMainView) { |
| // Story View. |
| swarm.frontView.detachedView.itemsView.showView(selArticle); |
| } else { |
| if (swarm.frontView.currentSection.inCurrentView(selArticle)) { |
| // Scroll horizontally if needed. |
| swarm.frontView.currentSection.dataSourceView |
| .showView(selArticle.dataSource); |
| DataSourceView dataView = |
| swarm.frontView.currentSection.findView(selArticle.dataSource); |
| if (dataView != null) { |
| dataView.itemsView.showView(selArticle); |
| } |
| } |
| } |
| } |
| } |
| |
| String getDataUriForImage(final img) { |
| // TODO(hiltonc,jimhug) eval perf of this vs. reusing one canvas element |
| final CanvasElement canvas = |
| new CanvasElement(height: img.height, width: img.width); |
| |
| final CanvasRenderingContext2D ctx = canvas.getContext("2d"); |
| ctx.drawImageScaled(img, 0, 0, img.width, img.height); |
| |
| return canvas.toDataUrl("image/png"); |
| } |
| |
| /** |
| * Update this view's selected appearance based on the currently selected |
| * Article. |
| */ |
| void _refreshSelected(curItem) { |
| if (curItem.value == item) { |
| addClass('sel'); |
| } else { |
| removeClass('sel'); |
| } |
| } |
| |
| void _saveToStorage(String thumbUrl, ImageElement img) { |
| // TODO(jimhug): Reimplement caching of images. |
| } |
| } |
| |
| /** |
| * An internal view of a story as text. In other words, the article is shown |
| * in-place as opposed to as an embedded web-page. |
| */ |
| class StoryContentView extends View { |
| final Swarm swarm; |
| final Article item; |
| |
| View _pagedStory; |
| |
| StoryContentView(this.swarm, this.item); |
| |
| get childViews => [_pagedStory]; |
| |
| Element render() { |
| final storyContent = |
| new Element.html('<div class="story-content">${item.htmlBody}</div>'); |
| for (Element element in storyContent.querySelectorAll( |
| "iframe, script, style, object, embed, frameset, frame")) { |
| element.remove(); |
| } |
| _pagedStory = new PagedContentView(new View.fromNode(storyContent)); |
| |
| // Modify all links to open in new windows.... |
| // TODO(jacobr): would it be better to add an event listener on click that |
| // intercepts these instead? |
| for (AnchorElement anchor in storyContent.querySelectorAll('a')) { |
| anchor.target = '_blank'; |
| } |
| |
| final date = DateUtils.toRecentTimeString(item.date); |
| final container = new Element.html(''' |
| <div class="story-view"> |
| <div class="story-text-view"> |
| <div class="story-header"> |
| <a class="story-title" href="${item.srcUrl}" target="_blank"> |
| ${item.title}</a> |
| <div class="story-byline"> |
| ${item.author} - ${item.dataSource.title} |
| </div> |
| <div class="story-dateline">$date</div> |
| </div> |
| <div class="paged-story"></div> |
| <div class="spacer"></div> |
| </div> |
| </div>'''); |
| |
| container.querySelector('.paged-story').replaceWith(_pagedStory.node); |
| |
| return container; |
| } |
| } |
| |
| class SectionView extends CompositeView { |
| final Section section; |
| final Swarm swarm; |
| final DataSourceViewFactory _viewFactory; |
| final View loadingText; |
| ListView<Feed> dataSourceView; |
| PageNumberView pageNumberView; |
| final PageState pageState; |
| |
| SectionView(this.swarm, this.section, this._viewFactory) |
| : loadingText = new View.html('<div class="loading-section"></div>'), |
| pageState = new PageState(), |
| super('section-view') { |
| addChild(loadingText); |
| } |
| |
| /** |
| * Hides the loading text, reloads the data sources, and shows them. |
| */ |
| void showSources() { |
| loadingText.node.style.display = 'none'; |
| |
| // Lazy initialize the data source view. |
| if (dataSourceView == null) { |
| // TODO(jacobr): use named arguments when available. |
| dataSourceView = new ListView<Feed>( |
| section.feeds, |
| _viewFactory, |
| true /* scrollable */, |
| false /* vertical */, |
| null /* selectedItem */, |
| true /* snapToItems */, |
| true /* paginate */, |
| true /* removeClippedViews */, |
| false, |
| /* showScrollbar */ |
| pageState); |
| dataSourceView.addClass("data-source-view"); |
| addChild(dataSourceView); |
| |
| pageNumberView = addChild(new PageNumberView(pageState)); |
| |
| node.style.opacity = '1'; |
| } else { |
| addChild(dataSourceView); |
| addChild(pageNumberView); |
| node.style.opacity = '1'; |
| } |
| |
| // TODO(jacobr): get rid of this call to reconfigure when it is not needed. |
| dataSourceView.scroller.reconfigure(() {}); |
| } |
| |
| /** |
| * Hides the data sources and shows the loading text. |
| */ |
| void hideSources() { |
| if (dataSourceView != null) { |
| node.style.opacity = '0.6'; |
| removeChild(dataSourceView); |
| removeChild(pageNumberView); |
| } |
| |
| loadingText.node.style.display = 'block'; |
| } |
| |
| set storyMode(bool inStoryMode) { |
| if (inStoryMode) { |
| addClass('hide-all-queries'); |
| } else { |
| removeClass('hide-all-queries'); |
| } |
| } |
| |
| /** |
| * Find the [DataSourceView] in this SectionView that's displaying the given |
| * [Feed]. |
| */ |
| DataSourceView findView(Feed dataSource) { |
| return dataSourceView.getSubview(dataSourceView.findIndex(dataSource)); |
| } |
| |
| bool inCurrentView(Article article) { |
| return dataSourceView.findIndex(article.dataSource) != null; |
| } |
| } |