// 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. 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) : super('front-view fullpage'), 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 { 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) : super(); Element render() => new Element.html('
'); 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() : super(); Element render() { return new Element.html('
'); } 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() : super(); Element render() { return new Element.html('
'); } 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 { 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
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('

${source.title}

')); // TODO(jacobr): use named arguments when available. itemsView = addChild(new VariableSizeListView
( 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('
')); // 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 states; ToggleButton(this.states) : super(), 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
{ 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) : super(); 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 = ''; } 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('''
$thumbnail
$title
$snippet
'''); // 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) : super(); get childViews => [_pagedStory]; Element render() { final storyContent = new Element.html('
${item.htmlBody}
'); 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('''
${item.title}
'''); 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 dataSourceView; PageNumberView pageNumberView; final PageState pageState; SectionView(this.swarm, this.section, this._viewFactory) : super('section-view'), loadingText = new View.html('
'), pageState = new PageState() { 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( 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; } }