From 182542916a5a8b9d21c3f08df0a4fee0153aa830 Mon Sep 17 00:00:00 2001 From: Jorgen Evens Date: Sat, 2 Jan 2021 02:00:09 +0100 Subject: [PATCH] [data-provider] Decouple data retrieval from rendering --- src/avatar.js | 347 +++------------------------------- src/components/image.js | 70 +++++++ src/components/text.js | 169 +++++++++++++++++ src/components/wrapper.js | 69 +++++++ src/data-provider.js | 167 ++++++++++++++++ src/index.js | 1 + src/sources/AvatarRedirect.js | 2 +- 7 files changed, 505 insertions(+), 320 deletions(-) create mode 100644 src/components/image.js create mode 100644 src/components/text.js create mode 100644 src/components/wrapper.js create mode 100644 src/data-provider.js diff --git a/src/avatar.js b/src/avatar.js index 9ed9899..b4df8fa 100644 --- a/src/avatar.js +++ b/src/avatar.js @@ -1,333 +1,42 @@ 'use strict'; -import React, { PureComponent } from 'react'; -import PropTypes from 'prop-types'; +import React from 'react'; import { Cache } from './cache'; import {withConfig, ConfigProvider} from './context'; -import {getRandomColor, parseSize, setGroupedTimeout} from './utils'; -import InternalState from './internal-state'; +import createAvatarDataProvider from './data-provider'; +import {getRandomColor} from './utils'; + +import Image from './components/image'; +import Text from './components/text'; export {getRandomColor} from './utils'; export {ConfigProvider} from './context'; export {Cache} from './cache'; -function matchSource(Source, props, cb) { - const { cache } = props; - const instance = new Source(props); - - if(!instance.isCompatible(props)) - return cb(); - - instance.get((state) => { - const failedBefore = state && state.src && - cache.hasSourceFailedBefore(state.src); - - if(!failedBefore && state) { - cb(state); - } else { - cb(); - } - }); -} - export default -function createAvatarComponent({ sources = [] }) { - - // Collect propTypes for each individual source - const sourcePropTypes = sources.reduce((r, s) => Object.assign(r, s.propTypes), {}); - - class Avatar extends PureComponent { - - static displayName = 'Avatar' - - static propTypes = { - // PropTypes defined on sources - ...sourcePropTypes, - - alt: PropTypes.string, - title: PropTypes.string, - className: PropTypes.string, - fgColor: PropTypes.string, - color: PropTypes.string, - colors: PropTypes.arrayOf(PropTypes.string), - round: PropTypes.oneOfType([ - PropTypes.bool, - PropTypes.string - ]), - style: PropTypes.object, - size: PropTypes.oneOfType([ - PropTypes.number, - PropTypes.string - ]), - textSizeRatio: PropTypes.number, - textMarginRatio: PropTypes.number, - unstyled: PropTypes.bool, - cache: PropTypes.object, - onClick: PropTypes.func - - } - - static defaultProps = { - className: '', - fgColor: '#FFF', - round: false, - size: 100, - textSizeRatio: 3, - textMarginRatio: .15, - unstyled: false - } - - constructor(props) { - super(props); - - this.state = { - internal: null, - src: null, - value: null, - color: props.color - }; - } - - componentDidMount() { - this.mounted = true; - this.fetch(); - } - - componentDidUpdate(prevProps) { - let needsUpdate = false; - - // This seems redundant - // - // Props that need to be in `state` are - // `value`, `src` and `color` - for (const prop in sourcePropTypes) - needsUpdate = needsUpdate || (prevProps[prop] !== this.props[prop]); - - if (needsUpdate) - setTimeout(this.fetch, 0); - } - - componentWillUnmount() { - this.mounted = false; - if (this.state.internal) { - this.state.internal.active = false; - } - } - - static getRandomColor = getRandomColor - - static Cache = Cache; - static ConfigProvider = ConfigProvider - - _createFetcher = (internal) => (errEvent) => { - const { cache } = this.props; - - if (!internal.isActive(this.state)) - return; - - // Mark img source as failed for future reference - if( errEvent && errEvent.type === 'error' ) - cache.sourceFailed(errEvent.target.src); - - const pointer = internal.sourcePointer; - if(sources.length === pointer) - return; - - const source = sources[pointer]; - - internal.sourcePointer++; - - matchSource(source, this.props, (nextState) => { - if (!nextState) - return setTimeout(internal.fetch, 0); - - if (!internal.isActive(this.state)) - return; - - // Reset other values to prevent them from sticking (#51) - nextState = { - src: null, - value: null, - color: null, - - ...nextState - }; - - this.setState(state => { - // Internal state has been reset => we received new props - return internal.isActive(state) ? nextState : {}; - }); - }); - } - - fetch = () => { - const internal = new InternalState(); - internal.fetch = this._createFetcher(internal); - - this.setState({ internal }, internal.fetch); - }; - - _scaleTextNode = (node, retryTTL = 16) => { - const { unstyled, textSizeRatio, textMarginRatio } = this.props; - - if (!node || unstyled || this.state.src || !this.mounted) - return; - - const spanNode = node.parentNode; - const tableNode = spanNode.parentNode; - - const { - width: containerWidth, - height: containerHeight - } = spanNode.getBoundingClientRect(); - - // Whenever the avatar element is not visible due to some CSS - // (such as display: none) on any parent component we will check - // whether the component has become visible. - // - // The time between checks grows up to half a second in an attempt - // to reduce flicker / performance issues. - if (containerWidth == 0 && containerHeight == 0) { - const ttl = Math.min(retryTTL * 1.5, 500); - setGroupedTimeout(() => this._scaleTextNode(node, ttl), ttl); - return; - } - - // If the tableNode (outer-container) does not have its fontSize set yet, - // we'll set it according to "textSizeRatio" - if (!tableNode.style.fontSize) { - const baseFontSize = containerHeight / textSizeRatio; - tableNode.style.fontSize = `${baseFontSize}px`; - } - - // Reset font-size such that scaling works correctly (#133) - spanNode.style.fontSize = null; - - // Measure the actual width of the text after setting the container size - const { width: textWidth } = node.getBoundingClientRect(); - - if (textWidth < 0) - return; - - // Calculate the maximum width for the text based on "textMarginRatio" - const maxTextWidth = containerWidth * (1 - (2 * textMarginRatio)); - - // If the text is too wide, scale it down by (maxWidth / actualWidth) - if (textWidth > maxTextWidth) - spanNode.style.fontSize = `calc(1em * ${maxTextWidth / textWidth})`; - } - - _renderAsImage() { - const { className, round, unstyled, alt, title, name, value } = this.props; - const { internal } = this.state; - const size = parseSize(this.props.size); - - const imageStyle = unstyled ? null : { - maxWidth: '100%', - width: size.str, - height: size.str, - borderRadius: (round === true ? '100%' : round) - }; - - return ( - {alt - ); - } - - _renderAsText() { - const { className, round, unstyled, title, name, value } = this.props; - const size = parseSize(this.props.size); - - const initialsStyle = unstyled ? null : { - width: size.str, - height: size.str, - lineHeight: 'initial', - textAlign: 'center', - color: this.props.fgColor, - background: this.state.color, - borderRadius: (round === true ? '100%' : round) - }; - - const tableStyle = unstyled ? null : { - display: 'table', - tableLayout: 'fixed', - width: '100%', - height: '100%' - }; - - const spanStyle = unstyled ? null : { - display: 'table-cell', - verticalAlign: 'middle', - fontSize: '100%', - whiteSpace: 'nowrap' - }; - - // Ensure the text node is updated and scaled when any of these - // values changed by calling the `_scaleTextNode` method using - // the correct `ref`. - const key = [ - this.state.value, - this.props.size - ].join(''); - - return ( -
-
- - - {this.state.value} - - -
-
- ); - } - - render() { - const { className, unstyled, round, style, onClick } = this.props; - const { src, sourceName } = this.state; - const size = parseSize(this.props.size); - - const hostStyle = unstyled ? null : { - display: 'inline-block', - verticalAlign: 'middle', - width: size.str, - height: size.str, - borderRadius: (round === true ? '100%' : round), - fontFamily: 'Helvetica, Arial, sans-serif', - ...style - }; - - const classNames = [ className, 'sb-avatar' ]; - - if (sourceName) { - const source = sourceName.toLowerCase() - .replace(/[^a-z0-9-]+/g, '-') // only allow alphanumeric - .replace(/^-+|-+$/g, ''); // trim `-` - classNames.push('sb-avatar--' + source); - } - - return ( -
- {src ? this._renderAsImage() : this._renderAsText()} -
- ); - } - } - - return Object.assign(withConfig(Avatar), { +function createAvatarComponent(options) { + + const DataProvider = createAvatarDataProvider(options); + + const Component = withConfig( + // eslint-disable-next-line react/display-name + React.forwardRef((props, ref) => ( + + {avatar => { + const Avatar = avatar.src ? Image : Text; + + return ( + + ); + }} + + )) + ); + + return Object.assign(Component, { getRandomColor, ConfigProvider, Cache diff --git a/src/components/image.js b/src/components/image.js new file mode 100644 index 0000000..d15e347 --- /dev/null +++ b/src/components/image.js @@ -0,0 +1,70 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { parseSize } from '../utils'; +import Wrapper from './wrapper'; + +export default +class AvatarImage extends React.PureComponent { + + static propTypes = { + alt: PropTypes.string, + title: PropTypes.string, + name: PropTypes.string, + value: PropTypes.string, + avatar: PropTypes.object, + + className: PropTypes.string, + unstyled: PropTypes.bool, + round: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.string + ]), + size: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.string + ]), + } + + static defaultProps = { + className: '', + round: false, + size: 100, + unstyled: false + } + + render() { + const { + className, + round, + unstyled, + alt, + title, + name, + value, + avatar + } = this.props; + + const size = parseSize(this.props.size); + + const imageStyle = unstyled ? null : { + maxWidth: '100%', + width: size.str, + height: size.str, + borderRadius: (round === true ? '100%' : round) + }; + + return ( + + {alt + + ); + } +} diff --git a/src/components/text.js b/src/components/text.js new file mode 100644 index 0000000..c1b6a6f --- /dev/null +++ b/src/components/text.js @@ -0,0 +1,169 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { parseSize, setGroupedTimeout } from '../utils'; +import Wrapper from './wrapper'; + +export default +class AvatarText extends React.PureComponent { + + static propTypes = { + title: PropTypes.string, + name: PropTypes.string, + value: PropTypes.string, + avatar: PropTypes.object, + + className: PropTypes.string, + unstyled: PropTypes.bool, + fgColor: PropTypes.string, + textSizeRatio: PropTypes.number, + textMarginRatio: PropTypes.number, + round: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.string + ]), + size: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.string + ]), + } + + static defaultProps = { + className: '', + fgColor: '#FFF', + round: false, + size: 100, + textSizeRatio: 3, + textMarginRatio: .15, + unstyled: false + } + + componentDidMount() { + this._mounted = true; + this._scaleTextNode(this._node); + } + + componentWillUnmount() { + this._mounted = false; + } + + _scaleTextNode = (node, retryTTL = 16) => { + const { + unstyled, + textSizeRatio, + textMarginRatio, + avatar + } = this.props; + + this._node = node; + + if (!node || unstyled || avatar.src || !this._mounted) + return; + + const spanNode = node.parentNode; + const tableNode = spanNode.parentNode; + + const { + width: containerWidth, + height: containerHeight + } = spanNode.getBoundingClientRect(); + + // Whenever the avatar element is not visible due to some CSS + // (such as display: none) on any parent component we will check + // whether the component has become visible. + // + // The time between checks grows up to half a second in an attempt + // to reduce flicker / performance issues. + if (containerWidth == 0 && containerHeight == 0) { + const ttl = Math.min(retryTTL * 1.5, 500); + setGroupedTimeout(() => this._scaleTextNode(node, ttl), ttl); + return; + } + + // If the tableNode (outer-container) does not have its fontSize set yet, + // we'll set it according to "textSizeRatio" + if (!tableNode.style.fontSize) { + const baseFontSize = containerHeight / textSizeRatio; + tableNode.style.fontSize = `${baseFontSize}px`; + } + + // Reset font-size such that scaling works correctly (#133) + spanNode.style.fontSize = null; + + // Measure the actual width of the text after setting the container size + const { width: textWidth } = node.getBoundingClientRect(); + + if (textWidth < 0) + return; + + // Calculate the maximum width for the text based on "textMarginRatio" + const maxTextWidth = containerWidth * (1 - (2 * textMarginRatio)); + + // If the text is too wide, scale it down by (maxWidth / actualWidth) + if (textWidth > maxTextWidth) + spanNode.style.fontSize = `calc(1em * ${maxTextWidth / textWidth})`; + } + + render() { + const { + className, + round, + unstyled, + title, + name, + value, + avatar + } = this.props; + + const size = parseSize(this.props.size); + + const initialsStyle = unstyled ? null : { + width: size.str, + height: size.str, + lineHeight: 'initial', + textAlign: 'center', + color: this.props.fgColor, + background: avatar.color, + borderRadius: (round === true ? '100%' : round) + }; + + const tableStyle = unstyled ? null : { + display: 'table', + tableLayout: 'fixed', + width: '100%', + height: '100%' + }; + + const spanStyle = unstyled ? null : { + display: 'table-cell', + verticalAlign: 'middle', + fontSize: '100%', + whiteSpace: 'nowrap' + }; + + // Ensure the text node is updated and scaled when any of these + // values changed by calling the `_scaleTextNode` method using + // the correct `ref`. + const key = [ + avatar.value, + this.props.size + ].join(''); + + return ( + +
+
+ + + {avatar.value} + + +
+
+
+ ); + } + +} diff --git a/src/components/wrapper.js b/src/components/wrapper.js new file mode 100644 index 0000000..3763e68 --- /dev/null +++ b/src/components/wrapper.js @@ -0,0 +1,69 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { parseSize } from '../utils'; + +export default +class AvatarWrapper extends React.PureComponent { + + static propTypes = { + className: PropTypes.string, + round: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.string + ]), + style: PropTypes.object, + size: PropTypes.oneOfType([ + PropTypes.number, + PropTypes.string + ]), + unstyled: PropTypes.bool, + avatar: PropTypes.object, + + onClick: PropTypes.func, + children: PropTypes.node, + } + + render() { + const { + className, + unstyled, + round, + style, + avatar, + onClick, + children, + + } = this.props; + const { sourceName } = avatar; + const size = parseSize(this.props.size); + + const hostStyle = unstyled ? null : { + display: 'inline-block', + verticalAlign: 'middle', + width: size.str, + height: size.str, + borderRadius: (round === true ? '100%' : round), + fontFamily: 'Helvetica, Arial, sans-serif', + ...style + }; + + const classNames = [ className, 'sb-avatar' ]; + + if (sourceName) { + const source = sourceName.toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') // only allow alphanumeric + .replace(/^-+|-+$/g, ''); // trim `-` + classNames.push('sb-avatar--' + source); + } + + return ( +
+ {children} +
+ ); + } + +} diff --git a/src/data-provider.js b/src/data-provider.js new file mode 100644 index 0000000..50403e9 --- /dev/null +++ b/src/data-provider.js @@ -0,0 +1,167 @@ +'use strict'; + +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; + +import { Cache } from './cache'; +import {withConfig, ConfigProvider} from './context'; +import InternalState from './internal-state'; + +export {getRandomColor} from './utils'; +export {ConfigProvider} from './context'; +export {Cache} from './cache'; + +function matchSource(Source, props, cb) { + const { cache } = props; + const instance = new Source(props); + + if(!instance.isCompatible(props)) + return cb(); + + instance.get((state) => { + const failedBefore = state && state.src && + cache.hasSourceFailedBefore(state.src); + + if(!failedBefore && state) { + cb(state); + } else { + cb(); + } + }); +} + +export default +function createAvatarDataProvider({ sources = [] }) { + + // Collect propTypes for each individual source + const sourcePropTypes = sources.reduce((r, s) => Object.assign(r, s.propTypes), {}); + + class AvatarDataProvider extends PureComponent { + + static displayName = 'AvatarDataProvider' + + static propTypes = { + // PropTypes defined on sources + ...sourcePropTypes, + + cache: PropTypes.object, + propertyName: PropTypes.string, + } + + static defaultProps = { + propertyName: 'avatar', + } + + constructor(props) { + super(props); + + this.state = { + internal: null, + src: null, + value: null, + color: null, + }; + } + + componentDidMount() { + this.fetch(); + } + + componentDidUpdate(prevProps) { + let needsUpdate = false; + + // This seems redundant + // + // Props that need to be in `state` are + // `value`, `src` and `color` + for (const prop in sourcePropTypes) + needsUpdate = needsUpdate || (prevProps[prop] !== this.props[prop]); + + if (needsUpdate) + setTimeout(this.fetch, 0); + } + + componentWillUnmount() { + if (this.state.internal) { + this.state.internal.active = false; + } + } + + static Cache = Cache; + static ConfigProvider = ConfigProvider + + _createFetcher = (internal) => (errEvent) => { + const { cache } = this.props; + + if (!internal.isActive(this.state)) + return; + + // Mark img source as failed for future reference + if( errEvent && errEvent.type === 'error' ) + cache.sourceFailed(errEvent.target.src); + + const pointer = internal.sourcePointer; + if(sources.length === pointer) + return; + + const source = sources[pointer]; + + internal.sourcePointer++; + + matchSource(source, this.props, (nextState) => { + if (!nextState) + return setTimeout(internal.fetch, 0); + + if (!internal.isActive(this.state)) + return; + + // Reset other values to prevent them from sticking (#51) + nextState = { + src: null, + value: null, + color: null, + + ...nextState + }; + + this.setState(state => { + // Internal state has been reset => we received new props + return internal.isActive(state) ? nextState : {}; + }); + }); + } + + fetch = () => { + const internal = new InternalState(); + internal.fetch = this._createFetcher(internal); + + this.setState({ internal }, internal.fetch); + }; + + render() { + const { children, propertyName } = this.props; + const { src, value, color, sourceName, internal } = this.state; + + const avatarData = { + src, + value, + color, + sourceName, + onRenderFailed: () => internal && internal.fetch() // eslint-disable-line + }; + + if (typeof children === 'function') + return children(avatarData); + + const child = React.Children.only(children); + return React.cloneElement(child, { + [propertyName]: avatarData, + }); + } + } + + return Object.assign(withConfig(AvatarDataProvider), { + ConfigProvider, + Cache + }); +} diff --git a/src/index.js b/src/index.js index 33abaad..302c27e 100644 --- a/src/index.js +++ b/src/index.js @@ -31,6 +31,7 @@ const SOURCES = [ export * from './avatar'; export { default as createAvatarComponent } from './avatar'; +export { default as createAvatarDataProvider } from './data-provider'; export default createAvatarComponent({ sources: SOURCES diff --git a/src/sources/AvatarRedirect.js b/src/sources/AvatarRedirect.js index 2acc824..9bcdff4 100644 --- a/src/sources/AvatarRedirect.js +++ b/src/sources/AvatarRedirect.js @@ -33,7 +33,7 @@ function createRedirectSource(network, property) { const query = size ? `size=${size}` : ''; const src = `${baseUrl}${network}/${id}?${query}`; - setState({ source: 'network', src }); + setState({ sourceName: network, src }); } }; }