diff options
-rw-r--r-- | packages/gitbook-core/src/actions/navigation.js | 106 | ||||
-rw-r--r-- | packages/gitbook-core/src/components/Link.js | 3 | ||||
-rw-r--r-- | packages/gitbook-core/src/components/PJAXWrapper.js | 112 | ||||
-rw-r--r-- | packages/gitbook-core/src/reducers/index.js | 2 | ||||
-rw-r--r-- | packages/gitbook-core/src/reducers/page.js | 13 | ||||
-rw-r--r-- | packages/gitbook-core/src/renderWithStore.js | 5 |
6 files changed, 237 insertions, 4 deletions
diff --git a/packages/gitbook-core/src/actions/navigation.js b/packages/gitbook-core/src/actions/navigation.js index 0d00687..af0fdff 100644 --- a/packages/gitbook-core/src/actions/navigation.js +++ b/packages/gitbook-core/src/actions/navigation.js @@ -1,13 +1,116 @@ +const ACTION_TYPES = require('./TYPES'); +const getPayload = require('../lib/getPayload'); +const SUPPORTED = ( + typeof window !== 'undefined' && + window.history && window.history.pushState && window.history.replaceState && + // pushState isn't reliable on iOS until 5. + !navigator.userAgent.match(/((iPod|iPhone|iPad).+\bOS\s+[1-4]\D|WebApps\/.+CFNetwork)/) +); + +let PUSH_ID = 0; + +/** + * Generate a new state to be pushed or replaced + * @param {Object} + */ +function genState() { + return { + id: (PUSH_ID++) + }; +} + +/** + * Push a new url into the navigation + * @param {String} uri + * @return {Action} action + */ +function pushURI(uri) { + return () => { + const state = genState(); + + if (SUPPORTED) { + window.history.pushState(state, '', uri); + } else { + redirect(uri); + } + }; +} + +/** + * Replace current state in navigation + * @param {String} uri + * @return {Action} action + */ +function replaceURI(uri) { + return () => { + const state = genState(); + + if (SUPPORTED) { + window.history.replaceState(state, '', uri); + } else { + redirect(uri); + } + }; +} + +/** + * Hard redirection + * @param {String} uri + * @return {Action} action + */ +function redirect(uri) { + return () => { + window.location.href = uri; + }; +} /** * Fetch a new page and update the store accordingly * @param {String} uri + * @param {Boolean} options.replace * @return {Action} action */ -function fetchPage(uri) { +function fetchPage(uri, options) { + const { replace } = options; + return (dispatch, getState) => { + const prevURI = location.href; + dispatch({ type: ACTION_TYPES.PAGE_FETCH_START }); + + if (replace) { + dispatch(replaceURI(uri)); + } else { + dispatch(pushURI(uri)); + } + + window.fetch(uri, { + credentials: 'include' + }) + .then( + response => { + return response.text(); + } + ) + .then( + html => { + const payload = getPayload(html); + + if (!payload) { + throw new Error('No payload found in page'); + } + + dispatch({ type: ACTION_TYPES.PAGE_FETCH_END, payload }); + } + ) + .catch( + error => { + dispatch(replaceURI(prevURI)); + dispatch(redirect(uri)); + dispatch({ type: ACTION_TYPES.PAGE_FETCH_ERROR, error }); + } + ); }; } @@ -21,6 +124,7 @@ function fetchArticle(article) { } module.exports = { + pushURI, fetchPage, fetchArticle }; diff --git a/packages/gitbook-core/src/components/Link.js b/packages/gitbook-core/src/components/Link.js index 2f909de..ade7461 100644 --- a/packages/gitbook-core/src/components/Link.js +++ b/packages/gitbook-core/src/components/Link.js @@ -1,4 +1,5 @@ const React = require('react'); +const ReactRedux = require('react-redux'); const SummaryArticleShape = require('../shapes/SummaryArticle'); const Link = React.createClass({ @@ -35,4 +36,4 @@ const Link = React.createClass({ } }); -module.exports = Link; +module.exports = ReactRedux.connect()(Link); diff --git a/packages/gitbook-core/src/components/PJAXWrapper.js b/packages/gitbook-core/src/components/PJAXWrapper.js new file mode 100644 index 0000000..f35a554 --- /dev/null +++ b/packages/gitbook-core/src/components/PJAXWrapper.js @@ -0,0 +1,112 @@ +const React = require('react'); +const ReactRedux = require('react-redux'); +const navigation = require('../actions/navigation'); + +/** + * Check if an element is inside a link + * @param {DOMElement} el + * @param {String} name + * @return {DOMElement|undefined + */ +function findParentByTagName(el, name) { + while (el && el !== document) { + if (el.tagName && el.tagName.toUpperCase() === name) { + return el; + } + el = el.parentNode; + } + + return false; +} + +/** + * Internal: Return the `href` component of given URL object with the hash + * portion removed. + * + * @param {Location|HTMLAnchorElement} location + * @return {String} + */ +function stripHash(location) { + return location.href.replace(/#.*/, ''); +} + +/** + * Test if a click event should be handled, + * return the new url if it's a normal lcick + */ +function getHrefForEvent(event) { + const link = findParentByTagName(event.target, 'A'); + + if (!link) + return; + + // Middle click, cmd click, and ctrl click should open + // links in a new tab as normal. + if (event.which > 1 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) + return; + + // Ignore cross origin links + if (location.protocol !== link.protocol || location.hostname !== link.hostname) + return; + + // Ignore case when a hash is being tacked on the current URL + if (link.href.indexOf('#') > -1 && stripHash(link) == stripHash(location)) + return; + + // Ignore event with default prevented + if (event.defaultPrevented) + return; + + // Explicitly ignored + if (link.getAttribute('data-nopjax')) + return; + + return link.href; +} + +/* + Wrapper to bind all navigation events to fetch pages. + */ + +const PJAXWrapper = React.createClass({ + propTypes: { + children: React.PropTypes.node, + dispatch: React.PropTypes.func + }, + + onClick(event) { + const { dispatch } = this.props; + const href = getHrefForEvent(event); + + if (!href) { + return; + } + + event.preventDefault(); + dispatch(navigation.fetchPage(href)); + + }, + + onPopState(event) { + const { dispatch } = this.props; + event.preventDefault(); + + dispatch(navigation.fetchPage(location.href, { replace: true })); + }, + + componentDidMount() { + document.addEventListener('click', this.onClick, false); + window.addEventListener('popstate', this.onPopState, false); + }, + + componentWillUnmount() { + document.removeEventListener('click', this.onClick, false); + window.removeEventListener('popstate', this.onPopState, false); + }, + + render() { + return React.Children.only(this.props.children); + } +}); + +module.exports = ReactRedux.connect()(PJAXWrapper); diff --git a/packages/gitbook-core/src/reducers/index.js b/packages/gitbook-core/src/reducers/index.js index 49ea489..4785c1c 100644 --- a/packages/gitbook-core/src/reducers/index.js +++ b/packages/gitbook-core/src/reducers/index.js @@ -1,3 +1,5 @@ +const ACTION_TYPES = require('../actions/TYPES'); + const composeReducer = require('../composeReducer'); const createReducer = require('../createReducer'); diff --git a/packages/gitbook-core/src/reducers/page.js b/packages/gitbook-core/src/reducers/page.js index 98764c0..275fce7 100644 --- a/packages/gitbook-core/src/reducers/page.js +++ b/packages/gitbook-core/src/reducers/page.js @@ -1,4 +1,5 @@ const { Record } = require('immutable'); +const ACTION_TYPES = require('../actions/TYPES'); const DEFAULTS = { title: '', @@ -16,5 +17,15 @@ class PageState extends Record(DEFAULTS) { } module.exports = (state, action) => { - return PageState.create(state); + state = PageState.create(state); + + switch (action.type) { + + case ACTION_TYPES.PAGE_FETCH_END: + return state.merge(action.payload.page); + + default: + return state; + + } }; diff --git a/packages/gitbook-core/src/renderWithStore.js b/packages/gitbook-core/src/renderWithStore.js index af86704..48fff89 100644 --- a/packages/gitbook-core/src/renderWithStore.js +++ b/packages/gitbook-core/src/renderWithStore.js @@ -1,6 +1,7 @@ const React = require('react'); const { Provider } = require('react-redux'); const { InjectedComponent } = require('./components/InjectedComponent'); +const PJAXWrapper = require('./components/PJAXWrapper'); /** * Render the application for a store @@ -10,7 +11,9 @@ const { InjectedComponent } = require('./components/InjectedComponent'); function renderWithStore(store) { return ( <Provider store={store}> - <InjectedComponent matching={{ role: 'Body' }} /> + <PJAXWrapper> + <InjectedComponent matching={{ role: 'Body' }} /> + </PJAXWrapper> </Provider> ); } |