mirror of
https://github.com/dart-lang/sdk
synced 2024-09-05 16:41:07 +00:00
![Stephen Adams](/assets/img/avatar_default.png)
TBR=sigmund@google.com Change-Id: Id0c197b99e2c82f04329217cbda13ef35c50ce48 Reviewed-on: https://dart-review.googlesource.com/49543 Reviewed-by: Stephen Adams <sra@google.com> Reviewed-by: Sigmund Cherem <sigmund@google.com> Commit-Queue: Stephen Adams <sra@google.com>
985 lines
29 KiB
Dart
985 lines
29 KiB
Dart
// 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('<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() : super();
|
|
|
|
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() : super();
|
|
|
|
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)
|
|
: 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<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) : 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 = '<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) : super();
|
|
|
|
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)
|
|
: super('section-view'),
|
|
loadingText = new View.html('<div class="loading-section"></div>'),
|
|
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<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;
|
|
}
|
|
}
|