├── .babelrc ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .storybook ├── config.js └── webpack.config.js ├── LICENSE ├── README.md ├── dist └── bundle.js ├── index.js ├── package-lock.json ├── package.json ├── src ├── Canvas.js ├── CanvasComponent.js ├── CanvasRenderer.js ├── CanvasUtils.js ├── Core.js ├── DrawingUtils.js ├── Easing.js ├── EventTypes.js ├── FontFace.js ├── FontUtils.js ├── FrameUtils.js ├── Gradient.js ├── Group.js ├── Image.js ├── ImageCache.js ├── ListView.js ├── ReactDOMComponentTree.js ├── ReactDOMFrameScheduling.js ├── RenderLayer.js ├── Surface.js ├── Text.js ├── clamp.js ├── hitTest.js ├── index.js ├── layoutNode.js └── measureText.js ├── stories ├── canvasStory.js ├── components │ └── Page.js ├── csslayout.js ├── customDrawStory.js ├── data.js ├── heatmapStory.js ├── index.js ├── listviewStory.js └── timeline.js ├── webpack.config.babel.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "react", 5 | "stage-2" 6 | ], 7 | "plugins": [ 8 | "transform-decorators-legacy" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "babel-eslint", 3 | env: { 4 | browser: true, 5 | es6: true, 6 | node: true 7 | }, 8 | plugins: ["prettier", "react"], 9 | rules: { 10 | "prettier/prettier": "error", 11 | "prefer-const": "error", 12 | "no-use-before-define": "error", 13 | "no-var": "error", 14 | "no-throw-literal": "error", 15 | // Light console usage is useful but remove debug logs before merging to master. 16 | "no-console": "off" 17 | }, 18 | extends: ["prettier", "plugin:react/recommended", "eslint:recommended"] 19 | }; 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .idea 4 | storybook-static 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | build 2 | .idea 3 | node_modules 4 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@storybook/react'; 2 | 3 | function loadStories() { 4 | require('../stories'); 5 | } 6 | 7 | configure(loadStories, module); 8 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | // you can use this file to add your custom webpack plugins, loaders and anything you like. 2 | // This is just the basic way to add additional webpack configurations. 3 | // For more information refer the docs: https://storybook.js.org/configurations/custom-webpack-config 4 | 5 | // IMPORTANT 6 | // When you add this file, we won't add the default configurations which is similar 7 | // to "React Create App". This only has babel loader to load JavaScript. 8 | 9 | module.exports = { 10 | plugins: [ 11 | // your custom plugins 12 | ], 13 | module: { 14 | loaders: [ 15 | // add your custom loaders. 16 | ], 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Flipboard 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, this 11 | list of conditions and the following disclaimer in the documentation and/or 12 | other materials provided with the distribution. 13 | 14 | * Neither the name of Flipboard nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-canvas 2 | 3 | This is a fork of [Flipboard/react-canvas](https://github.com/Flipboard/react-canvas) which: 4 | - Upgrades to React 16 and uses a custom renderer with `react-reconciler` 5 | - Converts to ES modules and modern ES6+ 6 | - Storybook for ease of testing examples 7 | - Removes the need to use [brfs](https://github.com/substack/brfs) and `transform-loader` when using webpack. 8 | 9 | This fork builds upon work by [CraigMorton](https://github.com/CraigMorton/react-canvas) and [CSBerger](https://github.com/CSberger/react-canvas) 10 | 11 | # Original repo's README 12 | 13 | [Introductory blog post](http://engineering.flipboard.com/2015/02/mobile-web) 14 | 15 | React Canvas adds the ability for React components to render to `` rather than DOM. 16 | 17 | This project is a work-in-progress. Though much of the code is in production on flipboard.com, the React canvas bindings are relatively new and the API is subject to change. 18 | 19 | ## Motivation 20 | 21 | Having a long history of building interfaces geared toward mobile devices, we found that the reason mobile web apps feel slow when compared to native apps is the DOM. CSS animations and transitions are the fastest path to smooth animations on the web, but they have several limitations. React Canvas leverages the fact that most modern mobile browsers now have hardware accelerated canvas. 22 | 23 | While there have been other attempts to bind canvas drawing APIs to React, they are more focused on visualizations and games. Where React Canvas differs is in the focus on building application user interfaces. The fact that it renders to canvas is an implementation detail. 24 | 25 | React Canvas brings some of the APIs web developers are familiar with and blends them with a high performance drawing engine. 26 | 27 | ## Installation 28 | 29 | React Canvas is available through npm: 30 | 31 | ```npm install react-canvas``` 32 | 33 | ## React Canvas Components 34 | 35 | React Canvas provides a set of standard React components that abstract the underlying rendering implementation. 36 | 37 | ### <Surface> 38 | 39 | **Surface** is the top-level component. Think of it as a drawing canvas in which you can place other components. 40 | 41 | ### <Layer> 42 | 43 | **Layer** is the the base component by which other components build upon. Common styles and properties such as top, width, left, height, backgroundColor and zIndex are expressed at this level. 44 | 45 | ### <Group> 46 | 47 | **Group** is a container component. Because React enforces that all components return a single component in `render()`, Groups can be useful for parenting a set of child components. The Group is also an important component for optimizing scrolling performance, as it allows the rendering engine to cache expensive drawing operations. 48 | 49 | ### <Text> 50 | 51 | **Text** is a flexible component that supports multi-line truncation, something which has historically been difficult and very expensive to do in DOM. 52 | 53 | ### <Image> 54 | 55 | **Image** is exactly what you think it is. However, it adds the ability to hide an image until it is fully loaded and optionally fade it in on load. 56 | 57 | ### <Gradient> 58 | 59 | **Gradient** can be used to set the background of a group or surface. 60 | ```javascript 61 | render() { 62 | ... 63 | return ( 64 | 65 | 67 | 68 | ); 69 | } 70 | getGradientColors(){ 71 | return [ 72 | { color: "transparent", position: 0 }, 73 | { color: "#000", position: 1 } 74 | ] 75 | } 76 | ``` 77 | 78 | ### <ListView> 79 | 80 | **ListView** is a touch scrolling container that renders a list of elements in a column. Think of it like UITableView for the web. It leverages many of the same optimizations that make table views on iOS and list views on Android fast. 81 | 82 | ## Events 83 | 84 | React Canvas components support the same event model as normal React components. However, not all event types are currently supported. 85 | 86 | For a full list of supported events see [EventTypes](lib/EventTypes.js). 87 | 88 | ## Building Components 89 | 90 | Here is a very simple component that renders text below an image: 91 | 92 | ```javascript 93 | var React = require('react'); 94 | var ReactCanvas = require('react-canvas'); 95 | 96 | var Surface = ReactCanvas.Surface; 97 | var Image = ReactCanvas.Image; 98 | var Text = ReactCanvas.Text; 99 | 100 | var MyComponent = React.createClass({ 101 | 102 | render: function () { 103 | var surfaceWidth = window.innerWidth; 104 | var surfaceHeight = window.innerHeight; 105 | var imageStyle = this.getImageStyle(); 106 | var textStyle = this.getTextStyle(); 107 | 108 | return ( 109 | 110 | 111 | 112 | Here is some text below an image. 113 | 114 | 115 | ); 116 | }, 117 | 118 | getImageHeight: function () { 119 | return Math.round(window.innerHeight / 2); 120 | }, 121 | 122 | getImageStyle: function () { 123 | return { 124 | top: 0, 125 | left: 0, 126 | width: window.innerWidth, 127 | height: this.getImageHeight() 128 | }; 129 | }, 130 | 131 | getTextStyle: function () { 132 | return { 133 | top: this.getImageHeight() + 10, 134 | left: 0, 135 | width: window.innerWidth, 136 | height: 20, 137 | lineHeight: 20, 138 | fontSize: 12 139 | }; 140 | } 141 | 142 | }); 143 | ``` 144 | 145 | ## ListView 146 | 147 | Many mobile interfaces involve an infinitely long scrolling list of items. React Canvas provides the ListView component to do just that. 148 | 149 | Because ListView virtualizes elements outside of the viewport, passing children to it is different than a normal React component where children are declared in render(). 150 | 151 | The `numberOfItemsGetter`, `itemHeightGetter` and `itemGetter` props are all required. 152 | 153 | ```javascript 154 | var ListView = ReactCanvas.ListView; 155 | 156 | var MyScrollingListView = React.createClass({ 157 | 158 | render: function () { 159 | return ( 160 | 164 | ); 165 | }, 166 | 167 | getNumberOfItems: function () { 168 | // Return the total number of items in the list 169 | }, 170 | 171 | getItemHeight: function () { 172 | // Return the height of a single item 173 | }, 174 | 175 | renderItem: function (index) { 176 | // Render the item at the given index, usually a 177 | }, 178 | 179 | }); 180 | ``` 181 | 182 | See the [timeline example](examples/timeline/app.js) for a more complete example. 183 | 184 | Currently, ListView requires that each item is of the same height. Future versions will support variable height items. 185 | 186 | ## Text sizing 187 | 188 | React Canvas provides the `measureText` function for computing text metrics. 189 | 190 | The [Page component](examples/timeline/components/Page.js) in the timeline example contains an example of using measureText to achieve precise multi-line ellipsized text. 191 | 192 | Custom fonts are not currently supported but will be added in a future version. 193 | 194 | ## css-layout 195 | 196 | There is experimental support for using [css-layout](https://github.com/facebook/css-layout) to style React Canvas components. This is a more expressive way of defining styles for a component using standard CSS styles and flexbox. 197 | 198 | Future versions may not support css-layout out of the box. The performance implications need to be investigated before baking this in as a core layout principle. 199 | 200 | See the [css-layout example](examples/css-layout). 201 | 202 | ## Accessibility 203 | 204 | This area needs further exploration. Using fallback content (the canvas DOM sub-tree) should allow screen readers such as VoiceOver to interact with the content. We've seen mixed results with the iOS devices we've tested. Additionally there is a standard for [focus management](http://www.w3.org/TR/2010/WD-2dcontext-20100304/#dom-context-2d-drawfocusring) that is not supported by browsers yet. 205 | 206 | One approach that was raised by [Bespin](http://vimeo.com/3195079) in 2009 is to keep a [parallel DOM](http://robertnyman.com/2009/04/03/mozilla-labs-online-code-editor-bespin/#comment-560310) in sync with the elements rendered in canvas. 207 | 208 | ## Running the examples 209 | 210 | ``` 211 | npm install 212 | npm start 213 | ``` 214 | 215 | This will start a live reloading server on port 8080. To override the default server and live reload ports, run `npm start` with PORT and/or RELOAD_PORT environment variables. 216 | 217 | **A note on NODE_ENV and React**: running the examples with `NODE_ENV=production` will noticeably improve scrolling performance. This is because React skips propType validation in production mode. 218 | 219 | 220 | ## Using with webpack 221 | 222 | The [brfs](https://github.com/substack/brfs) transform is required in order to use the project with webpack. 223 | 224 | ```bash 225 | npm install -g brfs 226 | npm install --save-dev transform-loader brfs 227 | ``` 228 | 229 | Then add the [brfs](https://github.com/substack/brfs) transform to your webpack config 230 | 231 | ```javascript 232 | module: { 233 | postLoaders: [ 234 | { loader: "transform?brfs" } 235 | ] 236 | } 237 | ``` 238 | 239 | ## Contributing 240 | 241 | We welcome pull requests for bug fixes, new features, and improvements to React Canvas. Contributors to the main repository must accept Flipboard's Apache-style [Individual Contributor License Agreement (CLA)](https://docs.google.com/forms/d/1gh9y6_i8xFn6pA15PqFeye19VqasuI9-bGp_e0owy74/viewform) before any changes can be merged. 242 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import ReactCanvas from "./src/index"; 2 | 3 | export const Text = ReactCanvas.Text; 4 | export const Group = ReactCanvas.Group; 5 | export const Gradient = ReactCanvas.Gradient; 6 | export const Layer = ReactCanvas.Layer; 7 | export const Surface = ReactCanvas.Surface; 8 | export const Image = ReactCanvas.Image; 9 | export const ListView = ReactCanvas.ListView; 10 | export const FontFace = ReactCanvas.FontFace; 11 | export const FrameUtils = ReactCanvas.FrameUtils; 12 | export const measureText = ReactCanvas.measureText; 13 | export const registerCustomComponent = ReactCanvas.registerCustomComponent; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@gfodor/react-canvas", 3 | "version": "1.5.0", 4 | "description": "High performance rendering for React components", 5 | "main": "dist/bundle.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/gfodor/react-canvas.git" 9 | }, 10 | "scripts": { 11 | "build": "./node_modules/.bin/webpack .", 12 | "storybook": "start-storybook -p 6006 -c .storybook", 13 | "build-storybook": "build-storybook -c .storybook" 14 | }, 15 | "keywords": [ 16 | "react", 17 | "canvas" 18 | ], 19 | "author": "Michael Johnston ", 20 | "license": "BSD-3-Clause", 21 | "homepage": "https://github.com/gfodor/react-canvas", 22 | "bugs": { 23 | "url": "https://github.com/gfodor/react-canvas/issues" 24 | }, 25 | "devDependencies": { 26 | "@storybook/react": "^3.4.5", 27 | "alea": "^0.0.9", 28 | "babel-core": "^6.26.3", 29 | "babel-eslint": "^8.2.3", 30 | "babel-loader": "^7.1.4", 31 | "babel-plugin-external-helpers": "^6.22.0", 32 | "babel-plugin-transform-class-properties": "^6.24.1", 33 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 34 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 35 | "babel-register": "^6.26.0", 36 | "babel-runtime": "^6.26.0", 37 | "d3-scale": "^1.0.6", 38 | "del": "^3.0.0", 39 | "eslint": "^4.1.1", 40 | "eslint-config-prettier": "^2.9.0", 41 | "eslint-plugin-prettier": "^2.6.0", 42 | "eslint-plugin-react": "^7.8.2", 43 | "jest": "^22.4.3", 44 | "lodash.range": "^3.2.0", 45 | "prettier": "^1.12.1", 46 | "webpack": "^4.0.0", 47 | "webpack-cli": "^2.1.3" 48 | }, 49 | "dependencies": { 50 | "@craigmorton/linebreak": "^0.4.5", 51 | "create-react-class": "^15.6.0", 52 | "css-layout": "^1.1.1", 53 | "fbjs": "^0.8.16", 54 | "multi-key-cache": "^1.0.2", 55 | "object-assign": "^4.0.1", 56 | "prop-types": "^15.6.1", 57 | "react": "^16.3.2", 58 | "react-dom": "^16.3.2", 59 | "react-reconciler": "^0.10.0", 60 | "scroller": "git://github.com/mjohnston/scroller" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Canvas.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // Note that this class intentionally does not use PooledClass. 4 | // DrawingUtils manages pooling for more fine-grained control. 5 | 6 | function Canvas(width, height, scale) { 7 | // Re-purposing an existing canvas element. 8 | if (!this._canvas) { 9 | this._canvas = document.createElement("canvas"); 10 | } 11 | 12 | this.width = width; 13 | this.height = height; 14 | this.scale = scale || window.devicePixelRatio; 15 | 16 | this._canvas.width = this.width * this.scale; 17 | this._canvas.height = this.height * this.scale; 18 | this._canvas.getContext("2d").scale(this.scale, this.scale); 19 | } 20 | 21 | Object.assign(Canvas.prototype, { 22 | getRawCanvas: function() { 23 | return this._canvas; 24 | }, 25 | 26 | getContext: function() { 27 | return this._canvas.getContext("2d"); 28 | } 29 | }); 30 | 31 | // PooledClass: 32 | 33 | // Be fairly conserative - we are potentially drawing a large number of medium 34 | // to large size images. 35 | Canvas.poolSize = 30; 36 | 37 | export default Canvas; 38 | -------------------------------------------------------------------------------- /src/CanvasComponent.js: -------------------------------------------------------------------------------- 1 | import RenderLayer from "./RenderLayer"; 2 | import { make } from "./FrameUtils"; 3 | import * as EventTypes from "./EventTypes"; 4 | import emptyObject from "fbjs/lib/emptyObject"; 5 | 6 | let LAYER_GUID = 1; 7 | 8 | export default class CanvasComponent { 9 | constructor(type) { 10 | this.type = type; 11 | this.subscriptions = new Map(); 12 | this.listeners = new Map(); 13 | this.node = new RenderLayer(this); 14 | this._layerId = LAYER_GUID++; 15 | } 16 | 17 | putEventListener = (type, listener) => { 18 | const subscriptions = this.subscriptions; 19 | const listeners = this.listeners; 20 | 21 | if (listeners.get(type) !== listener) { 22 | listeners.set(type, listener); 23 | } 24 | 25 | if (listener) { 26 | if (!subscriptions.has(type)) { 27 | subscriptions.set(type, this.node.subscribe(type, listener, this)); 28 | } 29 | } else { 30 | const subscription = subscriptions.get(type); 31 | if (subscription) { 32 | subscription(); 33 | subscriptions.delete(type); 34 | } 35 | } 36 | }; 37 | 38 | destroyEventListeners = () => { 39 | this.listeners.clear(); 40 | this.subscriptions.clear(); 41 | this.node.destroyEventListeners(); 42 | }; 43 | 44 | setStyleFromProps = (layer, props) => { 45 | let style = emptyObject; 46 | 47 | if (props.style) { 48 | style = props.style; 49 | layer._originalStyle = style; 50 | } else { 51 | layer._originalStyle = null; 52 | } 53 | 54 | if (!layer.frame) { 55 | layer.frame = make(0, 0, 0, 0); 56 | } 57 | 58 | const frame = layer.frame; 59 | const l = style.left || 0; 60 | const t = style.top || 0; 61 | const w = style.width || 0; 62 | const h = style.height || 0; 63 | 64 | if (frame.x !== l) frame.x = l; 65 | if (frame.y !== t) frame.y = t; 66 | if (frame.width !== w) frame.width = w; 67 | if (frame.height !== h) frame.height = h; 68 | 69 | // Common layer properties 70 | if (layer.alpha !== style.alpha) layer.alpha = style.alpha; 71 | 72 | if (layer.backgroundColor !== style.backgroundColor) 73 | layer.backgroundColor = style.backgroundColor; 74 | 75 | if (layer.borderColor !== style.borderColor) 76 | layer.borderColor = style.borderColor; 77 | 78 | if (layer.borderWidth !== style.borderWidth) 79 | layer.borderWidth = style.borderWidth; 80 | 81 | if (layer.borderRadius !== style.borderRadius) 82 | layer.borderRadius = style.borderRadius; 83 | 84 | if (layer.clipRect !== style.clipRect) layer.clipRect = style.clipRect; 85 | 86 | if (layer.scale !== style.scale) layer.scale = style.scale; 87 | 88 | if ( 89 | layer.translateX !== style.translateX || 90 | layer.translateY !== style.translateY 91 | ) { 92 | layer.translateX = style.translateX; 93 | layer.translateY = style.translateY; 94 | } 95 | 96 | if (layer.zIndex !== style.zIndex) layer.zIndex = style.zIndex; 97 | 98 | // Shadow 99 | if (layer.shadowColor !== style.shadowColor) 100 | layer.shadowColor = style.shadowColor; 101 | 102 | if (layer.shadowBlur !== style.shadowBlur) 103 | layer.shadowBlur = style.shadowBlur; 104 | 105 | if (layer.shadowOffsetX !== style.shadowOffsetX) 106 | layer.shadowOffsetX = style.shadowOffsetX; 107 | 108 | if (layer.shadowOffsetY !== style.shadowOffsetY) 109 | layer.shadowOffsetY = style.shadowOffsetY; 110 | }; 111 | 112 | applyCommonLayerProps = (prevProps, props) => { 113 | const layer = this.node; 114 | 115 | // Generate backing store ID as needed. 116 | if (props.useBackingStore && layer.backingStoreId !== this._layerId) { 117 | layer.backingStoreId = this._layerId; 118 | } else if (!props.useBackingStore && layer.backingStoreId) { 119 | layer.backingStoreId = null; 120 | } 121 | 122 | // Register events 123 | for (const type in EventTypes) { 124 | if (prevProps[type] !== props[type]) { 125 | this.putEventListener(EventTypes[type], props[type]); 126 | } 127 | } 128 | 129 | this.setStyleFromProps(layer, props); 130 | }; 131 | 132 | getLayer = () => this.node; 133 | 134 | /** 135 | * Resets all the state on this CanvasComponent so it can be added to a pool for re-use. 136 | * 137 | * @return {RenderLayer} 138 | */ 139 | reset = () => { 140 | this.destroyEventListeners(); 141 | this._originalStyle = null; 142 | this.node.reset(this); 143 | }; 144 | } 145 | -------------------------------------------------------------------------------- /src/CanvasRenderer.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import invariant from "fbjs/lib/invariant"; 3 | import emptyObject from "fbjs/lib/emptyObject"; 4 | import Gradient from "./Gradient"; 5 | import Text from "./Text"; 6 | import Group from "./Group"; 7 | import { RawImage } from "./Image"; 8 | import ReactDOMFrameScheduling from "./ReactDOMFrameScheduling"; 9 | import ReactFiberReconciler from "react-reconciler"; 10 | import CanvasComponent from "./CanvasComponent"; 11 | import { getClosestInstanceFromNode } from "./ReactDOMComponentTree"; 12 | 13 | const UPDATE_SIGNAL = {}; 14 | const MAX_POOLED_COMPONENTS_PER_TYPE = 1024; 15 | 16 | const componentConstructors = { 17 | Gradient: Gradient, 18 | Text: Text, 19 | Group: Group, 20 | RawImage: RawImage 21 | }; 22 | 23 | const componentPool = {}; 24 | 25 | const freeComponentToPool = component => { 26 | const type = component.type; 27 | 28 | if (!(component.type in componentPool)) { 29 | componentPool[type] = []; 30 | } 31 | 32 | const pool = componentPool[type]; 33 | 34 | if (pool.length < MAX_POOLED_COMPONENTS_PER_TYPE) { 35 | pool.push(component); 36 | } 37 | }; 38 | 39 | const freeComponentAndChildren = c => { 40 | if (!(c instanceof CanvasComponent)) return; 41 | 42 | const children = c.getLayer().children; 43 | 44 | for (let i = 0; i < children.length; i++) { 45 | const childLayer = children[i]; 46 | freeComponentAndChildren(childLayer.component); 47 | } 48 | 49 | c.reset(); 50 | freeComponentToPool(c); 51 | }; 52 | 53 | const CanvasHostConfig = { 54 | appendInitialChild(parentInstance, child) { 55 | if (typeof child === "string") { 56 | // Noop for string children of Text (eg {'foo'}{'bar'}) 57 | invariant(false, "Text children should already be flattened."); 58 | return; 59 | } 60 | 61 | child.getLayer().inject(parentInstance.getLayer()); 62 | }, 63 | 64 | createInstance(type, props /*, internalInstanceHandle*/) { 65 | let instance; 66 | 67 | const pool = componentPool[type]; 68 | 69 | if (pool && pool.length > 0) { 70 | instance = componentPool[type].pop(); 71 | } else { 72 | instance = new componentConstructors[type](type); 73 | } 74 | 75 | if (typeof instance.applyLayerProps !== "undefined") { 76 | instance.applyLayerProps({}, props); 77 | instance.getLayer().invalidateLayout(); 78 | } 79 | 80 | return instance; 81 | }, 82 | 83 | createTextInstance(text /*, rootContainerInstance, internalInstanceHandle*/) { 84 | return text; 85 | }, 86 | 87 | finalizeInitialChildren(/*domElement, type, props*/) { 88 | return false; 89 | }, 90 | 91 | getPublicInstance(instance) { 92 | return instance; 93 | }, 94 | 95 | prepareForCommit() { 96 | // Noop 97 | }, 98 | 99 | prepareUpdate(/*domElement, type, oldProps, newProps*/) { 100 | return UPDATE_SIGNAL; 101 | }, 102 | 103 | resetAfterCommit() { 104 | // Noop 105 | }, 106 | 107 | resetTextContent(/*domElement*/) { 108 | // Noop 109 | }, 110 | 111 | shouldDeprioritizeSubtree(/*type, props*/) { 112 | return false; 113 | }, 114 | 115 | getRootHostContext() { 116 | return emptyObject; 117 | }, 118 | 119 | getChildHostContext() { 120 | return emptyObject; 121 | }, 122 | 123 | scheduleDeferredCallback: ReactDOMFrameScheduling.rIC, 124 | 125 | shouldSetTextContent(type, props) { 126 | return ( 127 | typeof props.children === "string" || typeof props.children === "number" 128 | ); 129 | }, 130 | 131 | now: ReactDOMFrameScheduling.now, 132 | 133 | isPrimaryRenderer: false, 134 | 135 | useSyncScheduling: true, 136 | 137 | mutation: { 138 | appendChild(parentInstance, child) { 139 | const childLayer = child.getLayer(); 140 | const parentLayer = parentInstance.getLayer(); 141 | 142 | if (childLayer.parentLayer === parentLayer) { 143 | childLayer.moveToTop(); 144 | } else { 145 | childLayer.inject(parentLayer); 146 | } 147 | 148 | parentLayer.invalidateLayout(); 149 | }, 150 | 151 | appendChildToContainer(parentInstance, child) { 152 | const childLayer = child.getLayer(); 153 | const parentLayer = parentInstance.getLayer(); 154 | 155 | if (childLayer.parentLayer === parentLayer) { 156 | childLayer.moveToTop(); 157 | } else { 158 | childLayer.inject(parentLayer); 159 | } 160 | 161 | parentLayer.invalidateLayout(); 162 | }, 163 | 164 | insertBefore(parentInstance, child, beforeChild) { 165 | const parentLayer = parentInstance.getLayer(); 166 | child.getLayer().injectBefore(parentLayer, beforeChild.getLayer()); 167 | parentLayer.invalidateLayout(); 168 | }, 169 | 170 | insertInContainerBefore(parentInstance, child, beforeChild) { 171 | const parentLayer = parentInstance.getLayer(); 172 | child.getLayer().injectBefore(parentLayer, beforeChild.getLayer()); 173 | parentLayer.invalidateLayout(); 174 | }, 175 | 176 | removeChild(parentInstance, child) { 177 | const parentLayer = parentInstance.getLayer(); 178 | child.getLayer().remove(); 179 | freeComponentAndChildren(child); 180 | parentLayer.invalidateLayout(); 181 | }, 182 | 183 | removeChildFromContainer(parentInstance, child) { 184 | const parentLayer = parentInstance.getLayer(); 185 | child.getLayer().remove(); 186 | freeComponentAndChildren(child); 187 | parentLayer.invalidateLayout(); 188 | }, 189 | 190 | commitTextUpdate(/*textInstance, oldText, newText*/) { 191 | // Noop 192 | }, 193 | 194 | commitMount(/*instance, type, newProps*/) { 195 | // Noop 196 | }, 197 | 198 | commitUpdate(instance, updatePayload, type, oldProps, newProps) { 199 | if (typeof instance.applyLayerProps !== "undefined") { 200 | instance.applyLayerProps(oldProps, newProps); 201 | instance.getLayer().invalidateLayout(); 202 | } 203 | } 204 | } 205 | }; 206 | 207 | const CanvasRenderer = ReactFiberReconciler(CanvasHostConfig); 208 | 209 | CanvasRenderer.injectIntoDevTools({ 210 | findFiberByHostInstance: getClosestInstanceFromNode, 211 | bundleType: process.env.NODE_ENV !== "production" ? 1 : 0, 212 | version: React.version || 16, 213 | rendererPackageName: "react-canvas", 214 | getInspectorDataForViewTag: (...args) => { 215 | console.log(args); 216 | } 217 | }); 218 | 219 | CanvasRenderer.registerComponentConstructor = (name, ctor) => { 220 | componentConstructors[name] = ctor; 221 | }; 222 | 223 | export default CanvasRenderer; 224 | -------------------------------------------------------------------------------- /src/CanvasUtils.js: -------------------------------------------------------------------------------- 1 | import clamp from "./clamp"; 2 | import measureText from "./measureText"; 3 | 4 | /** 5 | * Draw an image into a . This operation requires that the image 6 | * already be loaded. 7 | * 8 | * @param {CanvasContext} ctx 9 | * @param {Image} image The source image (from ImageCache.get()) 10 | * @param {Number} x The x-coordinate to begin drawing 11 | * @param {Number} y The y-coordinate to begin drawing 12 | * @param {Number} width The desired width 13 | * @param {Number} height The desired height 14 | * @param {Object} options Available options are: 15 | * {Number} originalWidth 16 | * {Number} originalHeight 17 | * {Object} focusPoint {x,y} 18 | * {String} backgroundColor 19 | */ 20 | function drawImage(ctx, image, x, y, width, height, options) { 21 | options = options || {}; 22 | 23 | if (options.backgroundColor) { 24 | ctx.save(); 25 | ctx.fillStyle = options.backgroundColor; 26 | ctx.fillRect(x, y, width, height); 27 | ctx.restore(); 28 | } 29 | 30 | let dx = 0; 31 | let dy = 0; 32 | let dw = 0; 33 | let dh = 0; 34 | let sx = 0; 35 | let sy = 0; 36 | let sw = 0; 37 | let sh = 0; 38 | let scale; 39 | let focusPoint = options.focusPoint; 40 | 41 | const actualSize = { 42 | width: image.getWidth(), 43 | height: image.getHeight() 44 | }; 45 | 46 | scale = Math.max(width / actualSize.width, height / actualSize.height) || 1; 47 | scale = parseFloat(scale.toFixed(4), 10); 48 | 49 | const scaledSize = { 50 | width: actualSize.width * scale, 51 | height: actualSize.height * scale 52 | }; 53 | 54 | if (focusPoint) { 55 | // Since image hints are relative to image "original" dimensions (original != actual), 56 | // use the original size for focal point cropping. 57 | if (options.originalHeight) { 58 | focusPoint.x *= actualSize.height / options.originalHeight; 59 | focusPoint.y *= actualSize.height / options.originalHeight; 60 | } 61 | } else { 62 | // Default focal point to [0.5, 0.5] 63 | focusPoint = { 64 | x: actualSize.width * 0.5, 65 | y: actualSize.height * 0.5 66 | }; 67 | } 68 | 69 | // Clip the image to rectangle (sx, sy, sw, sh). 70 | sx = 71 | Math.round( 72 | clamp(width * 0.5 - focusPoint.x * scale, width - scaledSize.width, 0) 73 | ) * 74 | (-1 / scale); 75 | sy = 76 | Math.round( 77 | clamp(height * 0.5 - focusPoint.y * scale, height - scaledSize.height, 0) 78 | ) * 79 | (-1 / scale); 80 | sw = Math.round(actualSize.width - sx * 2); 81 | sh = Math.round(actualSize.height - sy * 2); 82 | 83 | // Scale the image to dimensions (dw, dh). 84 | dw = Math.round(width); 85 | dh = Math.round(height); 86 | 87 | // Draw the image on the canvas at coordinates (dx, dy). 88 | dx = Math.round(x); 89 | dy = Math.round(y); 90 | 91 | ctx.drawImage(image.getRawImage(), sx, sy, sw, sh, dx, dy, dw, dh); 92 | } 93 | 94 | /** 95 | * @param {CanvasContext} ctx 96 | * @param {String} text The text string to render 97 | * @param {Number} x The x-coordinate to begin drawing 98 | * @param {Number} y The y-coordinate to begin drawing 99 | * @param {Number} width The maximum allowed width 100 | * @param {Number} height The maximum allowed height 101 | * @param {FontFace} fontFace The FontFace to to use 102 | * @param {Object} options Available options are: 103 | * {Number} fontSize 104 | * {Number} lineHeight 105 | * {String} textAlign 106 | * {String} color 107 | * {String} backgroundColor 108 | */ 109 | function drawText(ctx, text, x, y, width, height, fontFace, _options) { 110 | let currX = x; 111 | let currY = y; 112 | let currText; 113 | const options = _options || {}; 114 | 115 | options.fontSize = options.fontSize || 16; 116 | options.lineHeight = options.lineHeight || 18; 117 | options.textAlign = options.textAlign || "left"; 118 | options.backgroundColor = options.backgroundColor || "transparent"; 119 | options.color = options.color || "#000"; 120 | 121 | const textMetrics = measureText( 122 | text, 123 | width, 124 | fontFace, 125 | options.fontSize, 126 | options.lineHeight 127 | ); 128 | 129 | ctx.save(); 130 | 131 | // Draw the background 132 | if (options.backgroundColor !== "transparent") { 133 | ctx.fillStyle = options.backgroundColor; 134 | ctx.fillRect(0, 0, width, height); 135 | } 136 | 137 | ctx.fillStyle = options.color; 138 | ctx.font = 139 | fontFace.attributes.style + 140 | " normal " + 141 | fontFace.attributes.weight + 142 | " " + 143 | options.fontSize + 144 | "px " + 145 | fontFace.family; 146 | 147 | textMetrics.lines.forEach(function(line, index) { 148 | currText = line.text; 149 | currY = 150 | index === 0 151 | ? y + options.fontSize 152 | : y + options.fontSize + options.lineHeight * index; 153 | 154 | // Account for text-align: left|right|center 155 | switch (options.textAlign) { 156 | case "center": 157 | currX = x + width / 2 - line.width / 2; 158 | break; 159 | case "right": 160 | currX = x + width - line.width; 161 | break; 162 | default: 163 | currX = x; 164 | } 165 | 166 | if ( 167 | index < textMetrics.lines.length - 1 && 168 | options.fontSize + options.lineHeight * (index + 1) > height 169 | ) { 170 | currText = currText.replace(/,?\s?\w+$/, "…"); 171 | } 172 | 173 | if (currY <= height + y) { 174 | ctx.fillText(currText, currX, currY); 175 | } 176 | }); 177 | 178 | ctx.restore(); 179 | } 180 | 181 | /** 182 | * Draw a linear gradient 183 | * 184 | * @param {CanvasContext} ctx 185 | * @param {Number} x1 gradient start-x coordinate 186 | * @param {Number} y1 gradient start-y coordinate 187 | * @param {Number} x2 gradient end-x coordinate 188 | * @param {Number} y2 gradient end-y coordinate 189 | * @param {Array} colorStops Array of {(String)color, (Number)position} values 190 | * @param {Number} x x-coordinate to begin fill 191 | * @param {Number} y y-coordinate to begin fill 192 | * @param {Number} width how wide to fill 193 | * @param {Number} height how tall to fill 194 | */ 195 | function drawGradient(ctx, x1, y1, x2, y2, colorStops, x, y, width, height) { 196 | ctx.save(); 197 | const grad = ctx.createLinearGradient(x1, y1, x2, y2); 198 | 199 | colorStops.forEach(function(colorStop) { 200 | grad.addColorStop(colorStop.position, colorStop.color); 201 | }); 202 | 203 | ctx.fillStyle = grad; 204 | ctx.fillRect(x, y, width, height); 205 | ctx.restore(); 206 | } 207 | 208 | export { drawImage, drawText, drawGradient }; 209 | -------------------------------------------------------------------------------- /src/Core.js: -------------------------------------------------------------------------------- 1 | const Core = { 2 | Layer: "Layer", 3 | Group: "Group", 4 | Text: "Text", 5 | Gradient: "Gradient" 6 | }; 7 | 8 | export default Core; 9 | -------------------------------------------------------------------------------- /src/DrawingUtils.js: -------------------------------------------------------------------------------- 1 | import ImageCache from "./ImageCache"; 2 | import { isFontLoaded } from "./FontUtils"; 3 | import FontFace from "./FontFace"; 4 | import { drawGradient, drawText, drawImage } from "./CanvasUtils"; 5 | import Canvas from "./Canvas"; 6 | 7 | // Global backing store cache 8 | let _backingStores = []; 9 | 10 | /** 11 | * Maintain a cache of backing for RenderLayer's which are accessible 12 | * through the RenderLayer's `backingStoreId` property. 13 | * 14 | * @param {String} id The unique `backingStoreId` for a RenderLayer 15 | * @return {HTMLCanvasElement} 16 | */ 17 | function getBackingStore(id) { 18 | for (let i = 0, len = _backingStores.length; i < len; i++) { 19 | if (_backingStores[i].id === id) { 20 | return _backingStores[i].canvas; 21 | } 22 | } 23 | return null; 24 | } 25 | 26 | /** 27 | * Purge a layer's backing store from the cache. 28 | * 29 | * @param {String} id The layer's backingStoreId 30 | */ 31 | function invalidateBackingStore(id) { 32 | for (let i = 0, len = _backingStores.length; i < len; i++) { 33 | if (_backingStores[i].id === id) { 34 | _backingStores.splice(i, 1); 35 | break; 36 | } 37 | } 38 | } 39 | 40 | /** 41 | * Purge the entire backing store cache. 42 | */ 43 | function invalidateAllBackingStores() { 44 | _backingStores = []; 45 | } 46 | 47 | /** 48 | * Check if a layer is using a given image URL. 49 | * 50 | * @param {RenderLayer} layer 51 | * @param {String} imageUrl 52 | * @return {Boolean} 53 | */ 54 | function layerContainsImage(layer, imageUrl) { 55 | // Check the layer itself. 56 | if (layer.type === "image" && layer.imageUrl === imageUrl) { 57 | return layer; 58 | } 59 | 60 | // Check the layer's children. 61 | if (layer.children) { 62 | for (let i = 0, len = layer.children.length; i < len; i++) { 63 | if (layerContainsImage(layer.children[i], imageUrl)) { 64 | return layer.children[i]; 65 | } 66 | } 67 | } 68 | 69 | return false; 70 | } 71 | 72 | /** 73 | * Check if a layer is using a given FontFace. 74 | * 75 | * @param {RenderLayer} layer 76 | * @param {FontFace} fontFace 77 | * @return {Boolean} 78 | */ 79 | function layerContainsFontFace(layer, fontFace) { 80 | // Check the layer itself. 81 | if ( 82 | layer.type === "text" && 83 | layer.fontFace && 84 | layer.fontFace.id === fontFace.id 85 | ) { 86 | return layer; 87 | } 88 | 89 | // Check the layer's children. 90 | if (layer.children) { 91 | for (let i = 0, len = layer.children.length; i < len; i++) { 92 | if (layerContainsFontFace(layer.children[i], fontFace)) { 93 | return layer.children[i]; 94 | } 95 | } 96 | } 97 | 98 | return false; 99 | } 100 | 101 | /** 102 | * Invalidates the backing stores for layers which contain an image layer 103 | * associated with the given imageUrl. 104 | * 105 | * @param {String} imageUrl 106 | */ 107 | function handleImageLoad(imageUrl) { 108 | _backingStores.forEach(function(backingStore) { 109 | if (layerContainsImage(backingStore.layer, imageUrl)) { 110 | invalidateBackingStore(backingStore.id); 111 | } 112 | }); 113 | } 114 | 115 | /** 116 | * Invalidates the backing stores for layers which contain a text layer 117 | * associated with the given font face. 118 | * 119 | * @param {FontFace} fontFace 120 | */ 121 | function handleFontLoad(fontFace) { 122 | _backingStores.forEach(function(backingStore) { 123 | if (layerContainsFontFace(backingStore.layer, fontFace)) { 124 | invalidateBackingStore(backingStore.id); 125 | } 126 | }); 127 | } 128 | 129 | /** 130 | * Draw base layer properties into a rendering context. 131 | * NOTE: The caller is responsible for calling save() and restore() as needed. 132 | * 133 | * @param {CanvasRenderingContext2d} ctx 134 | * @param {RenderLayer} layer 135 | */ 136 | function drawBaseRenderLayer(ctx, layer) { 137 | const frame = layer.frame; 138 | 139 | // Border radius: 140 | if (layer.borderRadius) { 141 | ctx.beginPath(); 142 | ctx.moveTo(frame.x + layer.borderRadius, frame.y); 143 | ctx.arcTo( 144 | frame.x + frame.width, 145 | frame.y, 146 | frame.x + frame.width, 147 | frame.y + frame.height, 148 | layer.borderRadius 149 | ); 150 | ctx.arcTo( 151 | frame.x + frame.width, 152 | frame.y + frame.height, 153 | frame.x, 154 | frame.y + frame.height, 155 | layer.borderRadius 156 | ); 157 | ctx.arcTo( 158 | frame.x, 159 | frame.y + frame.height, 160 | frame.x, 161 | frame.y, 162 | layer.borderRadius 163 | ); 164 | ctx.arcTo( 165 | frame.x, 166 | frame.y, 167 | frame.x + frame.width, 168 | frame.y, 169 | layer.borderRadius 170 | ); 171 | ctx.closePath(); 172 | 173 | // Create a clipping path when drawing an image or using border radius. 174 | if (layer.type === "image") { 175 | ctx.clip(); 176 | } 177 | 178 | // Border with border radius: 179 | if (layer.borderColor) { 180 | ctx.lineWidth = layer.borderWidth || 1; 181 | ctx.strokeStyle = layer.borderColor; 182 | ctx.stroke(); 183 | } 184 | } 185 | 186 | // Border color (no border radius): 187 | if (layer.borderColor && !layer.borderRadius) { 188 | ctx.lineWidth = layer.borderWidth || 1; 189 | ctx.strokeStyle = layer.borderColor; 190 | ctx.strokeRect(frame.x, frame.y, frame.width, frame.height); 191 | } 192 | 193 | // Shadow: 194 | ctx.shadowBlur = layer.shadowBlur; 195 | ctx.shadowColor = layer.shadowColor; 196 | ctx.shadowOffsetX = layer.shadowOffsetX; 197 | ctx.shadowOffsetY = layer.shadowOffsetY; 198 | 199 | // Background color: 200 | if (layer.backgroundColor) { 201 | ctx.fillStyle = layer.backgroundColor; 202 | if (layer.borderRadius) { 203 | // Fill the current path when there is a borderRadius set. 204 | ctx.fill(); 205 | } else { 206 | ctx.fillRect(frame.x, frame.y, frame.width, frame.height); 207 | } 208 | } 209 | } 210 | 211 | /** 212 | * @private 213 | */ 214 | function drawImageRenderLayer(ctx, layer) { 215 | drawBaseRenderLayer(ctx, layer); 216 | 217 | if (!layer.imageUrl) { 218 | return; 219 | } 220 | 221 | // Don't draw until loaded 222 | const image = ImageCache.get(layer.imageUrl); 223 | if (!image.isLoaded()) { 224 | return; 225 | } 226 | 227 | drawImage( 228 | ctx, 229 | image, 230 | layer.frame.x, 231 | layer.frame.y, 232 | layer.frame.width, 233 | layer.frame.height 234 | ); 235 | } 236 | 237 | /** 238 | * @private 239 | */ 240 | function drawTextRenderLayer(ctx, layer) { 241 | drawBaseRenderLayer(ctx, layer); 242 | 243 | // Fallback to standard font. 244 | const fontFace = layer.fontFace || FontFace.Default(); 245 | 246 | // Don't draw text until loaded 247 | if (!isFontLoaded(fontFace)) { 248 | return; 249 | } 250 | 251 | drawText( 252 | ctx, 253 | layer.text, 254 | layer.frame.x, 255 | layer.frame.y, 256 | layer.frame.width, 257 | layer.frame.height, 258 | fontFace, 259 | { 260 | fontSize: layer.fontSize, 261 | lineHeight: layer.lineHeight, 262 | textAlign: layer.textAlign, 263 | color: layer.color 264 | } 265 | ); 266 | } 267 | 268 | /** 269 | * @private 270 | */ 271 | function drawGradientRenderLayer(ctx, layer) { 272 | drawBaseRenderLayer(ctx, layer); 273 | 274 | // Default to linear gradient from top to bottom. 275 | const x1 = layer.x1 || layer.frame.x; 276 | const y1 = layer.y1 || layer.frame.y; 277 | const x2 = layer.x2 || layer.frame.x; 278 | const y2 = layer.y2 || layer.frame.y + layer.frame.height; 279 | drawGradient( 280 | ctx, 281 | x1, 282 | y1, 283 | x2, 284 | y2, 285 | layer.colorStops, 286 | layer.frame.x, 287 | layer.frame.y, 288 | layer.frame.width, 289 | layer.frame.height 290 | ); 291 | } 292 | 293 | const layerTypesToDrawFunction = { 294 | image: drawImageRenderLayer, 295 | text: drawTextRenderLayer, 296 | gradient: drawGradientRenderLayer, 297 | group: drawBaseRenderLayer 298 | }; 299 | 300 | function getDrawFunction(type) { 301 | return layerTypesToDrawFunction.hasOwnProperty(type) 302 | ? layerTypesToDrawFunction[type] 303 | : drawBaseRenderLayer; 304 | } 305 | 306 | function registerLayerType(type, drawFunction) { 307 | if (layerTypesToDrawFunction.hasOwnProperty(type)) { 308 | throw new Error(`type ${type} already registered`); 309 | } 310 | 311 | layerTypesToDrawFunction[type] = drawFunction; 312 | } 313 | 314 | /** 315 | * @private 316 | */ 317 | function sortByZIndexAscending(layerA, layerB) { 318 | return (layerA.zIndex || 0) - (layerB.zIndex || 0); 319 | } 320 | 321 | let drawCacheableRenderLayer = null; 322 | let drawRenderLayer = null; 323 | 324 | function drawChildren(layer, ctx) { 325 | const children = layer.children; 326 | if (children.length === 0) return; 327 | 328 | // Opimization 329 | if (children.length === 1) { 330 | drawRenderLayer(ctx, children[0]); 331 | } else if (children.length === 2) { 332 | const c0 = children[0]; 333 | const c1 = children[1]; 334 | 335 | if (c0.zIndex < c1.zIndex) { 336 | drawRenderLayer(ctx, c0); 337 | drawRenderLayer(ctx, c1); 338 | } else { 339 | drawRenderLayer(ctx, c1); 340 | drawRenderLayer(ctx, c0); 341 | } 342 | } else { 343 | children 344 | .slice() 345 | .sort(sortByZIndexAscending) 346 | .forEach(function(childLayer) { 347 | drawRenderLayer(ctx, childLayer); 348 | }); 349 | } 350 | } 351 | 352 | /** 353 | * Draw a RenderLayer instance to a context. 354 | * 355 | * @param {CanvasRenderingContext2d} ctx 356 | * @param {RenderLayer} layer 357 | */ 358 | drawRenderLayer = (ctx, layer) => { 359 | const drawFunction = getDrawFunction(layer.type); 360 | 361 | // Performance: avoid drawing hidden layers. 362 | if (typeof layer.alpha === "number" && layer.alpha <= 0) { 363 | return; 364 | } 365 | 366 | // Establish drawing context for certain properties: 367 | // - alpha 368 | // - translate 369 | const saveContext = 370 | (layer.alpha !== null && layer.alpha < 1) || 371 | (layer.translateX || layer.translateY); 372 | 373 | if (saveContext) { 374 | ctx.save(); 375 | 376 | // Alpha: 377 | if (layer.alpha !== null && layer.alpha < 1) { 378 | ctx.globalAlpha = layer.alpha; 379 | } 380 | 381 | // Translation: 382 | if (layer.translateX || layer.translateY) { 383 | ctx.translate(layer.translateX || 0, layer.translateY || 0); 384 | } 385 | } 386 | 387 | // If the layer is bitmap-cacheable, draw in a pooled off-screen canvas. 388 | // We disable backing stores on pad since we flip there. 389 | if (layer.backingStoreId) { 390 | drawCacheableRenderLayer(ctx, layer, drawFunction); 391 | } else { 392 | ctx.save(); 393 | 394 | // Draw 395 | drawFunction && drawFunction(ctx, layer); 396 | ctx.restore(); 397 | 398 | // Draw child layers, sorted by their z-index. 399 | if (layer.children) { 400 | drawChildren(layer, ctx); 401 | } 402 | } 403 | 404 | // Pop the context state if we established a new drawing context. 405 | if (saveContext) { 406 | ctx.restore(); 407 | } 408 | } 409 | 410 | /** 411 | * Draw a bitmap-cacheable layer into a pooled . The result will be 412 | * drawn into the given context. This will populate the layer backing store 413 | * cache with the result. 414 | * 415 | * @param {CanvasRenderingContext2d} ctx 416 | * @param {RenderLayer} layer 417 | * @param {Function} drawFunction 418 | * @private 419 | */ 420 | drawCacheableRenderLayer = (ctx, layer, drawFunction) => { 421 | // See if there is a pre-drawn canvas in the pool. 422 | let backingStore = getBackingStore(layer.backingStoreId); 423 | const backingStoreScale = layer.scale || window.devicePixelRatio; 424 | const frameOffsetY = layer.frame.y; 425 | const frameOffsetX = layer.frame.x; 426 | let backingContext; 427 | 428 | if (!backingStore) { 429 | if (_backingStores.length >= Canvas.poolSize) { 430 | // Re-use the oldest backing store once we reach the pooling limit. 431 | backingStore = _backingStores[0].canvas; 432 | Canvas.call( 433 | backingStore, 434 | layer.frame.width, 435 | layer.frame.height, 436 | backingStoreScale 437 | ); 438 | 439 | // Move the re-use canvas to the front of the queue. 440 | _backingStores[0].id = layer.backingStoreId; 441 | _backingStores[0].canvas = backingStore; 442 | _backingStores.push(_backingStores.shift()); 443 | } else { 444 | // Create a new backing store, we haven't yet reached the pooling limit 445 | backingStore = new Canvas( 446 | layer.frame.width, 447 | layer.frame.height, 448 | backingStoreScale 449 | ); 450 | _backingStores.push({ 451 | id: layer.backingStoreId, 452 | layer: layer, 453 | canvas: backingStore 454 | }); 455 | } 456 | 457 | // Draw into the backing at (0, 0) - we will later use the 458 | // to draw the layer as an image at the proper coordinates. 459 | backingContext = backingStore.getContext("2d"); 460 | layer.translate(-frameOffsetX, -frameOffsetY); 461 | 462 | // Draw default properties, such as background color. 463 | backingContext.save(); 464 | 465 | // Custom drawing operations 466 | drawFunction && drawFunction(backingContext, layer); 467 | backingContext.restore(); 468 | 469 | // Draw child layers, sorted by their z-index. 470 | if (layer.children) { 471 | drawChildren(layer, backingContext); 472 | } 473 | 474 | // Restore layer's original frame. 475 | layer.translate(frameOffsetX, frameOffsetY); 476 | } 477 | 478 | // We have the pre-rendered canvas ready, draw it into the destination canvas. 479 | if (layer.clipRect) { 480 | // Fill the clipping rect in the destination canvas. 481 | const sx = (layer.clipRect.x - layer.frame.x) * backingStoreScale; 482 | const sy = (layer.clipRect.y - layer.frame.y) * backingStoreScale; 483 | const sw = layer.clipRect.width * backingStoreScale; 484 | const sh = layer.clipRect.height * backingStoreScale; 485 | const dx = layer.clipRect.x; 486 | const dy = layer.clipRect.y; 487 | const dw = layer.clipRect.width; 488 | const dh = layer.clipRect.height; 489 | 490 | // No-op for zero size rects. iOS / Safari will throw an exception. 491 | if (sw > 0 && sh > 0) { 492 | ctx.drawImage( 493 | backingStore.getRawCanvas(), 494 | sx, 495 | sy, 496 | sw, 497 | sh, 498 | dx, 499 | dy, 500 | dw, 501 | dh 502 | ); 503 | } 504 | } else { 505 | // Fill the entire canvas 506 | ctx.drawImage( 507 | backingStore.getRawCanvas(), 508 | layer.frame.x, 509 | layer.frame.y, 510 | layer.frame.width, 511 | layer.frame.height 512 | ); 513 | } 514 | }; 515 | 516 | export { 517 | drawBaseRenderLayer, 518 | drawRenderLayer, 519 | invalidateBackingStore, 520 | invalidateAllBackingStores, 521 | handleImageLoad, 522 | handleFontLoad, 523 | layerContainsImage, 524 | layerContainsFontFace, 525 | registerLayerType 526 | }; 527 | -------------------------------------------------------------------------------- /src/Easing.js: -------------------------------------------------------------------------------- 1 | // Penner easing equations 2 | // https://gist.github.com/gre/1650294 3 | 4 | function linear(t) { 5 | return t; 6 | } 7 | 8 | function easeInQuad(t) { 9 | return Math.pow(t, 2); 10 | } 11 | 12 | function easeOutQuad(t) { 13 | return t * (2 - t); 14 | } 15 | 16 | function easeInOutQuad(t) { 17 | return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; 18 | } 19 | 20 | function easeInCubic(t) { 21 | return t * t * t; 22 | } 23 | 24 | function easeOutCubic(t) { 25 | return --t * t * t + 1; 26 | } 27 | 28 | function easeInOutCubic(t) { 29 | return t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1; 30 | } 31 | 32 | export { 33 | linear, 34 | easeInQuad, 35 | easeOutQuad, 36 | easeInOutQuad, 37 | easeInCubic, 38 | easeOutCubic, 39 | easeInOutCubic 40 | }; 41 | -------------------------------------------------------------------------------- /src/EventTypes.js: -------------------------------------------------------------------------------- 1 | // Supported events that RenderLayer's can subscribe to. 2 | 3 | const onTouchStart = "touchstart"; 4 | const onTouchMove = "touchmove"; 5 | const onTouchEnd = "touchend"; 6 | const onTouchCancel = "touchcancel"; 7 | const onMouseDown = "mousedown"; 8 | const onMouseUp = "mouseup"; 9 | const onMouseMove = "mousemove"; 10 | const onMouseOver = "mouseover"; 11 | const onMouseOut = "mouseout"; 12 | const onClick = "click"; 13 | const onContextMenu = "contextmenu"; 14 | const onDoubleClick = "dblclick"; 15 | 16 | export { 17 | onTouchStart, 18 | onTouchMove, 19 | onTouchEnd, 20 | onTouchCancel, 21 | onMouseDown, 22 | onMouseUp, 23 | onMouseMove, 24 | onMouseOver, 25 | onMouseOut, 26 | onClick, 27 | onContextMenu, 28 | onDoubleClick 29 | }; 30 | -------------------------------------------------------------------------------- /src/FontFace.js: -------------------------------------------------------------------------------- 1 | import MultiKeyCache from "multi-key-cache"; 2 | const _fontFaces = new MultiKeyCache(); 3 | 4 | /** 5 | * @internal 6 | */ 7 | function getCacheKey(family, url, attributes) { 8 | const cacheKey = [family, url]; 9 | 10 | for (const entry of Object.entries(attributes)) { 11 | cacheKey.push(entry[0]); 12 | cacheKey.push(entry[1]); 13 | } 14 | 15 | return cacheKey; 16 | } 17 | 18 | /** 19 | * @param {String} family The CSS font-family value 20 | * @param {String} url The remote URL for the font file 21 | * @param {Object} attributes Font attributes supported: style, weight 22 | * @return {Object} 23 | */ 24 | function FontFace(family, url, attributes) { 25 | let fontFace; 26 | 27 | attributes = attributes || {}; 28 | attributes.style = attributes.style || "normal"; 29 | attributes.weight = attributes.weight || 400; 30 | 31 | const cacheKey = getCacheKey(family, url, attributes); 32 | fontFace = _fontFaces.get(cacheKey); 33 | 34 | if (!fontFace) { 35 | fontFace = {}; 36 | fontFace.id = JSON.stringify(cacheKey); 37 | fontFace.family = family; 38 | fontFace.url = url; 39 | fontFace.attributes = attributes; 40 | _fontFaces.set(cacheKey, fontFace); 41 | } 42 | 43 | return fontFace; 44 | } 45 | 46 | /** 47 | * Helper for retrieving the default family by weight. 48 | * 49 | * @param {Number} fontWeight 50 | * @return {FontFace} 51 | */ 52 | FontFace.Default = function(fontWeight) { 53 | return FontFace("sans-serif", null, { weight: fontWeight }); 54 | }; 55 | 56 | export default FontFace; 57 | -------------------------------------------------------------------------------- /src/FontUtils.js: -------------------------------------------------------------------------------- 1 | const _useNativeImpl = typeof window.FontFace !== "undefined"; 2 | const _pendingFonts = {}; 3 | const _loadedFonts = {}; 4 | const _failedFonts = {}; 5 | 6 | const kFontLoadTimeout = 3000; 7 | 8 | /** 9 | * Helper method for created a hidden with a given font. 10 | * Uses TypeKit's default test string, which is said to result 11 | * in highly varied measured widths when compared to the default font. 12 | * @internal 13 | */ 14 | function createTestNode(family, attributes) { 15 | const span = document.createElement("span"); 16 | span.setAttribute("data-fontfamily", family); 17 | span.style.cssText = 18 | "position:absolute; left:-5000px; top:-5000px; visibility:hidden;" + 19 | 'font-size:100px; font-family:"' + 20 | family + 21 | '", Helvetica;font-weight: ' + 22 | attributes.weight + 23 | ";" + 24 | "font-style:" + 25 | attributes.style + 26 | ";"; 27 | span.innerHTML = "BESs"; 28 | return span; 29 | } 30 | 31 | /** 32 | * @internal 33 | */ 34 | function handleFontLoad(fontFace, timeout) { 35 | const error = timeout 36 | ? "Exceeded load timeout of " + kFontLoadTimeout + "ms" 37 | : null; 38 | 39 | if (!error) { 40 | _loadedFonts[fontFace.id] = true; 41 | } else { 42 | _failedFonts[fontFace.id] = error; 43 | } 44 | 45 | // Execute pending callbacks. 46 | _pendingFonts[fontFace.id].callbacks.forEach(function(callback) { 47 | callback(error); 48 | }); 49 | 50 | // Clean up DOM 51 | if (_pendingFonts[fontFace.id].defaultNode) { 52 | document.body.removeChild(_pendingFonts[fontFace.id].defaultNode); 53 | } 54 | if (_pendingFonts[fontFace.id].testNode) { 55 | document.body.removeChild(_pendingFonts[fontFace.id].testNode); 56 | } 57 | 58 | // Clean up waiting queue 59 | delete _pendingFonts[fontFace.id]; 60 | } 61 | 62 | /** 63 | * Check if a font face has loaded 64 | * @param {FontFace} fontFace 65 | * @return {Boolean} 66 | */ 67 | function isFontLoaded(fontFace) { 68 | // For remote URLs, check the cache. System fonts (sans url) assume loaded. 69 | return _loadedFonts[fontFace.id] !== undefined || !fontFace.url; 70 | } 71 | 72 | /** 73 | * Load a remote font and execute a callback. 74 | * @param {FontFace} fontFace The font to Load 75 | * @param {Function} callback Function executed upon font Load 76 | */ 77 | function loadFontNormal(fontFace, callback) { 78 | // See if we've previously loaded it. 79 | if (_loadedFonts[fontFace.id]) { 80 | return callback(null); 81 | } 82 | 83 | // See if we've previously failed to load it. 84 | if (_failedFonts[fontFace.id]) { 85 | return callback(_failedFonts[fontFace.id]); 86 | } 87 | 88 | // System font: assume already loaded. 89 | if (!fontFace.url) { 90 | return callback(null); 91 | } 92 | 93 | // Font load is already in progress: 94 | if (_pendingFonts[fontFace.id]) { 95 | _pendingFonts[fontFace.id].callbacks.push(callback); 96 | return; 97 | } 98 | 99 | // Create the test 's for measuring. 100 | const defaultNode = createTestNode("Helvetica", fontFace.attributes); 101 | const testNode = createTestNode(fontFace.family, fontFace.attributes); 102 | document.body.appendChild(testNode); 103 | document.body.appendChild(defaultNode); 104 | 105 | _pendingFonts[fontFace.id] = { 106 | startTime: Date.now(), 107 | defaultNode: defaultNode, 108 | testNode: testNode, 109 | callbacks: [callback] 110 | }; 111 | 112 | // Font watcher 113 | const checkFont = function() { 114 | const currWidth = testNode.getBoundingClientRect().width; 115 | const defaultWidth = defaultNode.getBoundingClientRect().width; 116 | const loaded = currWidth !== defaultWidth; 117 | 118 | if (loaded) { 119 | handleFontLoad(fontFace, null); 120 | } else { 121 | // Timeout? 122 | if ( 123 | Date.now() - _pendingFonts[fontFace.id].startTime >= 124 | kFontLoadTimeout 125 | ) { 126 | handleFontLoad(fontFace, true); 127 | } else { 128 | requestAnimationFrame(checkFont); 129 | } 130 | } 131 | }; 132 | 133 | // Start watching 134 | checkFont(); 135 | } 136 | 137 | // Internal 138 | // ======== 139 | 140 | /** 141 | * Native FontFace loader implementation 142 | * @internal 143 | */ 144 | function loadFontNative(fontFace, callback) { 145 | // See if we've previously loaded it. 146 | if (_loadedFonts[fontFace.id]) { 147 | return callback(null); 148 | } 149 | 150 | // See if we've previously failed to load it. 151 | if (_failedFonts[fontFace.id]) { 152 | return callback(_failedFonts[fontFace.id]); 153 | } 154 | 155 | // System font: assume it's installed. 156 | if (!fontFace.url) { 157 | return callback(null); 158 | } 159 | 160 | // Font load is already in progress: 161 | if (_pendingFonts[fontFace.id]) { 162 | _pendingFonts[fontFace.id].callbacks.push(callback); 163 | return; 164 | } 165 | 166 | _pendingFonts[fontFace.id] = { 167 | startTime: Date.now(), 168 | callbacks: [callback] 169 | }; 170 | 171 | // Use font loader API 172 | const theFontFace = new window.FontFace( 173 | fontFace.family, 174 | "url(" + fontFace.url + ")", 175 | fontFace.attributes 176 | ); 177 | 178 | theFontFace.load().then( 179 | function() { 180 | _loadedFonts[fontFace.id] = true; 181 | callback(null); 182 | }, 183 | function(err) { 184 | _failedFonts[fontFace.id] = err; 185 | callback(err); 186 | } 187 | ); 188 | } 189 | 190 | const loadFont = _useNativeImpl ? loadFontNative : loadFontNormal; 191 | 192 | export { isFontLoaded, loadFont }; 193 | -------------------------------------------------------------------------------- /src/FrameUtils.js: -------------------------------------------------------------------------------- 1 | function Frame(x, y, width, height) { 2 | this.x = x; 3 | this.y = y; 4 | this.width = width; 5 | this.height = height; 6 | } 7 | 8 | /** 9 | * Get a frame object 10 | * 11 | * @param {Number} x 12 | * @param {Number} y 13 | * @param {Number} width 14 | * @param {Number} height 15 | * @return {Frame} 16 | */ 17 | function make(x, y, width, height) { 18 | return new Frame(x, y, width, height); 19 | } 20 | 21 | /** 22 | * Return a zero size anchored at (0, 0). 23 | * 24 | * @return {Frame} 25 | */ 26 | function zero() { 27 | return make(0, 0, 0, 0); 28 | } 29 | 30 | /** 31 | * Return a cloned frame 32 | * 33 | * @param {Frame} frame 34 | * @return {Frame} 35 | */ 36 | function clone(frame) { 37 | return make(frame.x, frame.y, frame.width, frame.height); 38 | } 39 | 40 | /** 41 | * Creates a new frame by a applying edge insets. This method accepts CSS 42 | * shorthand notation e.g. inset(myFrame, 10, 0); 43 | * 44 | * @param {Frame} frame 45 | * @param {Number} top 46 | * @param {Number} right 47 | * @param {?Number} bottom 48 | * @param {?Number} left 49 | * @return {Frame} 50 | */ 51 | function inset(frame, top, right, bottom, left) { 52 | const frameCopy = clone(frame); 53 | 54 | // inset(myFrame, 10, 0) => inset(myFrame, 10, 0, 10, 0) 55 | if (typeof bottom === "undefined") { 56 | bottom = top; 57 | left = right; 58 | } 59 | 60 | // inset(myFrame, 10) => inset(myFrame, 10, 10, 10, 10) 61 | if (typeof right === "undefined") { 62 | right = bottom = left = top; 63 | } 64 | 65 | frameCopy.x += left; 66 | frameCopy.y += top; 67 | frameCopy.height -= top + bottom; 68 | frameCopy.width -= left + right; 69 | 70 | return frameCopy; 71 | } 72 | 73 | /** 74 | * Compute the intersection region between 2 frames. 75 | * 76 | * @param {Frame} frame 77 | * @param {Frame} otherFrame 78 | * @return {Frame} 79 | */ 80 | function intersection(frame, otherFrame) { 81 | const x = Math.max(frame.x, otherFrame.x); 82 | const width = Math.min( 83 | frame.x + frame.width, 84 | otherFrame.x + otherFrame.width 85 | ); 86 | const y = Math.max(frame.y, otherFrame.y); 87 | const height = Math.min( 88 | frame.y + frame.height, 89 | otherFrame.y + otherFrame.height 90 | ); 91 | if (width >= x && height >= y) { 92 | return make(x, y, width - x, height - y); 93 | } 94 | return null; 95 | } 96 | 97 | /** 98 | * Compute the union of two frames 99 | * 100 | * @param {Frame} frame 101 | * @param {Frame} otherFrame 102 | * @return {Frame} 103 | */ 104 | function union(frame, otherFrame) { 105 | const x1 = Math.min(frame.x, otherFrame.x); 106 | const x2 = Math.max(frame.x + frame.width, otherFrame.x + otherFrame.width); 107 | const y1 = Math.min(frame.y, otherFrame.y); 108 | const y2 = Math.max(frame.y + frame.height, otherFrame.y + otherFrame.height); 109 | return make(x1, y1, x2 - x1, y2 - y1); 110 | } 111 | 112 | /** 113 | * Determine if 2 frames intersect each other 114 | * 115 | * @param {Frame} frame 116 | * @param {Frame} otherFrame 117 | * @return {Boolean} 118 | */ 119 | function intersects(frame, otherFrame) { 120 | return !( 121 | otherFrame.x > frame.x + frame.width || 122 | otherFrame.x + otherFrame.width < frame.x || 123 | otherFrame.y > frame.y + frame.height || 124 | otherFrame.y + otherFrame.height < frame.y 125 | ); 126 | } 127 | 128 | export { make, zero, clone, inset, intersection, intersects, union }; 129 | -------------------------------------------------------------------------------- /src/Gradient.js: -------------------------------------------------------------------------------- 1 | import CanvasComponent from "./CanvasComponent"; 2 | 3 | const LAYER_TYPE = "gradient"; 4 | 5 | class Gradient extends CanvasComponent { 6 | displayName = "Gradient"; 7 | 8 | applyLayerProps = (prevProps, props) => { 9 | const layer = this.node; 10 | 11 | if (layer.type !== LAYER_TYPE) { 12 | layer.type = LAYER_TYPE; 13 | } 14 | 15 | if (layer.colorStops !== props.colorStops) { 16 | layer.colorStops = props.colorStops || []; 17 | } 18 | 19 | this.applyCommonLayerProps(prevProps, props); 20 | }; 21 | } 22 | 23 | export default Gradient; 24 | -------------------------------------------------------------------------------- /src/Group.js: -------------------------------------------------------------------------------- 1 | import CanvasComponent from "./CanvasComponent"; 2 | 3 | const LAYER_TYPE = "group"; 4 | 5 | class Group extends CanvasComponent { 6 | applyLayerProps = (prevProps, props) => { 7 | const layer = this.node; 8 | 9 | if (layer.type !== LAYER_TYPE) { 10 | layer.type = LAYER_TYPE; 11 | } 12 | 13 | this.applyCommonLayerProps(prevProps, props); 14 | }; 15 | 16 | render() { 17 | return []; 18 | } 19 | } 20 | 21 | export default Group; 22 | -------------------------------------------------------------------------------- /src/Image.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import CanvasComponent from "./CanvasComponent"; 4 | import Core from "./Core"; 5 | import ImageCache from "./ImageCache"; 6 | import { easeInCubic } from "./Easing"; 7 | import clamp from "./clamp"; 8 | 9 | const RawImageName = "RawImage"; 10 | const { Group } = Core; 11 | 12 | const FADE_DURATION = 200; 13 | 14 | const LAYER_TYPE = "image"; 15 | 16 | export class RawImage extends CanvasComponent { 17 | applyLayerProps = (prevProps, props) => { 18 | const layer = this.node; 19 | 20 | if (layer.type !== LAYER_TYPE) { 21 | layer.type = LAYER_TYPE; 22 | } 23 | 24 | if (layer.imageUrl !== props.src) { 25 | layer.imageUrl = props.src; 26 | } 27 | 28 | this.applyCommonLayerProps(prevProps, props); 29 | }; 30 | } 31 | 32 | export default class Image extends React.Component { 33 | static propTypes = { 34 | src: PropTypes.string.isRequired, 35 | style: PropTypes.object, 36 | useBackingStore: PropTypes.bool, 37 | fadeIn: PropTypes.bool, 38 | fadeInDuration: PropTypes.number 39 | }; 40 | 41 | constructor(props) { 42 | super(props); 43 | const loaded = ImageCache.get(props.src).isLoaded(); 44 | 45 | this.state = { 46 | loaded: loaded, 47 | imageAlpha: loaded ? 1 : 0 48 | }; 49 | } 50 | 51 | componentDidMount() { 52 | ImageCache.get(this.props.src).on("load", this.handleImageLoad); 53 | } 54 | 55 | componentWillUnmount() { 56 | if (this._pendingAnimationFrame) { 57 | cancelAnimationFrame(this._pendingAnimationFrame); 58 | this._pendingAnimationFrame = null; 59 | } 60 | ImageCache.get(this.props.src).removeListener("load", this.handleImageLoad); 61 | } 62 | 63 | componentDidUpdate(prevProps) { 64 | if (this.props.src !== prevProps.src) { 65 | ImageCache.get(prevProps.src).removeListener( 66 | "load", 67 | this.handleImageLoad 68 | ); 69 | ImageCache.get(this.props.src).on("load", this.handleImageLoad); 70 | const loaded = ImageCache.get(this.props.src).isLoaded(); 71 | this.setState({ loaded: loaded }); 72 | } 73 | 74 | if (this.rawImageRef) { 75 | this.rawImageRef.getLayer().invalidateLayout(); 76 | } 77 | } 78 | 79 | setRawImageRef = ref => (this.rawImageRef = ref); 80 | setGroupRef = ref => (this.groupRef = ref); 81 | 82 | render() { 83 | const imageStyle = Object.assign({}, this.props.style); 84 | const style = Object.assign({}, this.props.style); 85 | const backgroundStyle = Object.assign({}, this.props.style); 86 | const useBackingStore = this.state.loaded 87 | ? this.props.useBackingStore 88 | : false; 89 | 90 | // Hide the image until loaded. 91 | imageStyle.alpha = this.state.imageAlpha; 92 | 93 | // Hide opaque background if image loaded so that images with transparent 94 | // do not render on top of solid color. 95 | style.backgroundColor = imageStyle.backgroundColor = null; 96 | backgroundStyle.alpha = clamp(1 - this.state.imageAlpha, 0, 1); 97 | 98 | return ( 99 | 100 | 101 | 102 | 108 | 109 | ); 110 | } 111 | 112 | handleImageLoad = () => { 113 | let imageAlpha = 1; 114 | if (this.props.fadeIn) { 115 | imageAlpha = 0; 116 | this._animationStartTime = Date.now(); 117 | this._pendingAnimationFrame = requestAnimationFrame( 118 | this.stepThroughAnimation 119 | ); 120 | } 121 | this.setState({ loaded: true, imageAlpha: imageAlpha }); 122 | }; 123 | 124 | stepThroughAnimation = () => { 125 | const fadeInDuration = this.props.fadeInDuration || FADE_DURATION; 126 | let alpha = easeInCubic( 127 | (Date.now() - this._animationStartTime) / fadeInDuration 128 | ); 129 | alpha = clamp(alpha, 0, 1); 130 | this.setState({ imageAlpha: alpha }); 131 | if (alpha < 1) { 132 | this._pendingAnimationFrame = requestAnimationFrame( 133 | this.stepThroughAnimation 134 | ); 135 | } 136 | }; 137 | } 138 | -------------------------------------------------------------------------------- /src/ImageCache.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from "events"; 2 | 3 | const NOOP = function() {}; 4 | 5 | function Img(src) { 6 | this._originalSrc = src; 7 | this._img = new Image(); 8 | this._img.onload = this.emit.bind(this, "load"); 9 | this._img.onerror = this.emit.bind(this, "error"); 10 | this._img.crossOrigin = true; 11 | this._img.src = src; 12 | 13 | // The default impl of events emitter will throw on any 'error' event unless 14 | // there is at least 1 handler. Logging anything in this case is unnecessary 15 | // since the browser console will log it too. 16 | this.on("error", NOOP); 17 | 18 | // Default is just 10. 19 | this.setMaxListeners(100); 20 | } 21 | 22 | Object.assign(Img.prototype, EventEmitter.prototype, { 23 | /** 24 | * Pooling owner looks for this 25 | */ 26 | destructor: function() { 27 | // Make sure we aren't leaking callbacks. 28 | this.removeAllListeners(); 29 | }, 30 | 31 | /** 32 | * Retrieve the original image URL before browser normalization 33 | * 34 | * @return {String} 35 | */ 36 | getOriginalSrc: function() { 37 | return this._originalSrc; 38 | }, 39 | 40 | /** 41 | * Retrieve a reference to the underyling node. 42 | * 43 | * @return {HTMLImageElement} 44 | */ 45 | getRawImage: function() { 46 | return this._img; 47 | }, 48 | 49 | /** 50 | * Retrieve the loaded image width 51 | * 52 | * @return {Number} 53 | */ 54 | getWidth: function() { 55 | return this._img.naturalWidth; 56 | }, 57 | 58 | /** 59 | * Retrieve the loaded image height 60 | * 61 | * @return {Number} 62 | */ 63 | getHeight: function() { 64 | return this._img.naturalHeight; 65 | }, 66 | 67 | /** 68 | * @return {Bool} 69 | */ 70 | isLoaded: function() { 71 | return this._img.naturalHeight > 0; 72 | } 73 | }); 74 | 75 | const kInstancePoolLength = 300; 76 | 77 | const _instancePool = { 78 | length: 0, 79 | // Keep all the nodes in memory. 80 | elements: {}, 81 | 82 | // Push with 0 frequency 83 | push: function(hash, data) { 84 | this.length++; 85 | this.elements[hash] = { 86 | hash: hash, // Helps identifying 87 | freq: 0, 88 | data: data 89 | }; 90 | }, 91 | 92 | get: function(path) { 93 | const element = this.elements[path]; 94 | 95 | if (element) { 96 | element.freq++; 97 | return element.data; 98 | } 99 | 100 | return null; 101 | }, 102 | 103 | // used to explicitely remove the path 104 | removeElement: function(path) { 105 | // Now almighty GC can claim this soul 106 | const element = this.elements[path]; 107 | delete this.elements[path]; 108 | this.length--; 109 | return element; 110 | }, 111 | 112 | _reduceLeastUsed: function(least, currentHash) { 113 | const current = _instancePool.elements[currentHash]; 114 | 115 | if (least.freq > current.freq) { 116 | return current; 117 | } 118 | 119 | return least; 120 | }, 121 | 122 | popLeastUsed: function() { 123 | const reducer = _instancePool._reduceLeastUsed; 124 | const minUsed = Object.keys(this.elements).reduce(reducer, { 125 | freq: Infinity 126 | }); 127 | 128 | if (minUsed.hash) { 129 | return this.removeElement(minUsed.hash); 130 | } 131 | 132 | return null; 133 | } 134 | }; 135 | 136 | const ImageCache = { 137 | /** 138 | * Retrieve an image from the cache 139 | * 140 | * @return {Img} 141 | */ 142 | get: function(src) { 143 | let image = _instancePool.get(src); 144 | if (!image) { 145 | // Awesome LRU 146 | image = new Img(src); 147 | if (_instancePool.length >= kInstancePoolLength) { 148 | _instancePool.popLeastUsed().destructor(); 149 | } 150 | _instancePool.push(image.getOriginalSrc(), image); 151 | } 152 | return image; 153 | } 154 | }; 155 | 156 | export default ImageCache; 157 | -------------------------------------------------------------------------------- /src/ListView.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import React, { Component } from "react"; 4 | import PropTypes from "prop-types"; 5 | import Scroller from "scroller"; 6 | import Core from "./Core"; 7 | const { Group } = Core; 8 | const MAX_CACHED_ITEMS = 100; 9 | 10 | class ListView extends Component { 11 | static propTypes = { 12 | style: PropTypes.object, 13 | numberOfItemsGetter: PropTypes.func.isRequired, 14 | itemHeightGetter: PropTypes.func.isRequired, 15 | itemGetter: PropTypes.func.isRequired, 16 | snapping: PropTypes.bool, 17 | scrollingDeceleration: PropTypes.number, 18 | scrollingPenetrationAcceleration: PropTypes.number, 19 | onScroll: PropTypes.func 20 | }; 21 | 22 | static defaultProps = { 23 | style: { left: 0, top: 0, width: 0, height: 0 }, 24 | snapping: false, 25 | scrollingDeceleration: 0.95, 26 | scrollingPenetrationAcceleration: 0.08 27 | }; 28 | 29 | state = { 30 | scrollTop: 0 31 | }; 32 | 33 | constructor(props) { 34 | super(props); 35 | 36 | this._itemCache = new Map(); 37 | this._groupCache = new Map(); 38 | } 39 | 40 | componentDidMount() { 41 | this.createScroller(); 42 | this.updateScrollingDimensions(); 43 | } 44 | 45 | render() { 46 | if (this._itemCache.size > MAX_CACHED_ITEMS) { 47 | this._itemCache.clear(); 48 | this._groupCache.clear(); 49 | } 50 | 51 | const items = this.getVisibleItemIndexes().map(this.renderItem); 52 | return React.createElement( 53 | Group, 54 | { 55 | style: this.props.style, 56 | onTouchStart: this.handleTouchStart, 57 | onTouchMove: this.handleTouchMove, 58 | onTouchEnd: this.handleTouchEnd, 59 | onMouseDown: this.handleMouseDown, 60 | onMouseUp: this.handleMouseUp, 61 | onMouseOut: this.handleMouseOut, 62 | onMouseMove: this.handleMouseMove, 63 | onTouchCancel: this.handleTouchEnd 64 | }, 65 | items 66 | ); 67 | } 68 | 69 | renderItem = itemIndex => { 70 | const item = this.props.itemGetter(itemIndex, this.state.scrollTop); 71 | const priorItem = this._itemCache.get(itemIndex); 72 | const itemHeight = this.props.itemHeightGetter(); 73 | 74 | let group; 75 | 76 | if (item === priorItem) { 77 | // Item hasn't changed, we can re-use the previous Group element after adjusting style. 78 | group = this._groupCache.get(itemIndex); 79 | } else { 80 | group = React.createElement( 81 | Group, 82 | { 83 | style: { top: 0, left: 0, zIndex: itemIndex }, 84 | useBackingStore: true, 85 | key: itemIndex 86 | }, 87 | item 88 | ); 89 | 90 | this._itemCache.set(itemIndex, item); 91 | this._groupCache.set(itemIndex, group); 92 | } 93 | 94 | if (group.props.style.width !== this.props.style.width) { 95 | group.props.style.width = this.props.style.width; 96 | } 97 | 98 | if (group.props.style.height !== itemHeight) { 99 | group.props.style.height = itemHeight; 100 | } 101 | 102 | group.props.style.translateY = 103 | itemIndex * itemHeight - this.state.scrollTop; 104 | 105 | return group; 106 | }; 107 | 108 | // Events 109 | // ====== 110 | 111 | handleTouchStart = e => { 112 | if (this.scroller) { 113 | this.scroller.doTouchStart(e.touches, e.timeStamp); 114 | } 115 | }; 116 | 117 | handleTouchMove = e => { 118 | if (this.scroller) { 119 | e.preventDefault(); 120 | this.scroller.doTouchMove(e.touches, e.timeStamp, e.scale); 121 | } 122 | }; 123 | 124 | handleTouchEnd = e => { 125 | this.handleScrollRelease(e); 126 | }; 127 | 128 | handleMouseDown = e => { 129 | //if (e.button !== 2) return; 130 | 131 | if (this.scroller) { 132 | this.scroller.doTouchStart([e], e.timeStamp); 133 | } 134 | }; 135 | 136 | handleMouseMove = e => { 137 | if (this.scroller) { 138 | e.preventDefault(); 139 | this.scroller.doTouchMove([e], e.timeStamp); 140 | } 141 | }; 142 | 143 | handleMouseUp = e => { 144 | //if (e.button !== 2) return; 145 | 146 | this.handleScrollRelease(e); 147 | }; 148 | 149 | handleMouseOut = e => { 150 | //if (e.button !== 2) return; 151 | 152 | this.handleScrollRelease(e); 153 | }; 154 | 155 | handleScrollRelease = e => { 156 | if (this.scroller) { 157 | this.scroller.doTouchEnd(e.timeStamp); 158 | if (this.props.snapping) { 159 | this.updateScrollingDeceleration(); 160 | } 161 | } 162 | }; 163 | 164 | handleScroll = (left, top) => { 165 | this.setState({ scrollTop: top }); 166 | if (this.props.onScroll) { 167 | this.props.onScroll(top); 168 | } 169 | }; 170 | 171 | // Scrolling 172 | // ========= 173 | 174 | createScroller = () => { 175 | const options = { 176 | scrollingX: false, 177 | scrollingY: true, 178 | decelerationRate: this.props.scrollingDeceleration, 179 | penetrationAcceleration: this.props.scrollingPenetrationAcceleration 180 | }; 181 | this.scroller = new Scroller(this.handleScroll, options); 182 | }; 183 | 184 | updateScrollingDimensions = () => { 185 | const width = this.props.style.width; 186 | const height = this.props.style.height; 187 | const scrollWidth = width; 188 | const scrollHeight = 189 | this.props.numberOfItemsGetter() * this.props.itemHeightGetter(); 190 | this.scroller.setDimensions(width, height, scrollWidth, scrollHeight); 191 | }; 192 | 193 | getVisibleItemIndexes = () => { 194 | const itemIndexes = []; 195 | const itemHeight = this.props.itemHeightGetter(); 196 | const itemCount = this.props.numberOfItemsGetter(); 197 | const scrollTop = this.state.scrollTop; 198 | let itemScrollTop = 0; 199 | 200 | for (let index = 0; index < itemCount; index++) { 201 | itemScrollTop = index * itemHeight - scrollTop; 202 | 203 | // Item is completely off-screen bottom 204 | if (itemScrollTop >= this.props.style.height) { 205 | continue; 206 | } 207 | 208 | // Item is completely off-screen top 209 | if (itemScrollTop <= -this.props.style.height) { 210 | continue; 211 | } 212 | 213 | // Part of item is on-screen. 214 | itemIndexes.push(index); 215 | } 216 | 217 | return itemIndexes; 218 | }; 219 | 220 | updateScrollingDeceleration = () => { 221 | let currVelocity = this.scroller.__decelerationVelocityY; 222 | const currScrollTop = this.state.scrollTop; 223 | let targetScrollTop = 0; 224 | let estimatedEndScrollTop = currScrollTop; 225 | 226 | while (Math.abs(currVelocity).toFixed(6) > 0) { 227 | estimatedEndScrollTop += currVelocity; 228 | currVelocity *= this.props.scrollingDeceleration; 229 | } 230 | 231 | // Find the page whose estimated end scrollTop is closest to 0. 232 | let closestZeroDelta = Infinity; 233 | const pageHeight = this.props.itemHeightGetter(); 234 | const pageCount = this.props.numberOfItemsGetter(); 235 | let pageScrollTop; 236 | 237 | for (let pageIndex = 0, len = pageCount; pageIndex < len; pageIndex++) { 238 | pageScrollTop = pageHeight * pageIndex - estimatedEndScrollTop; 239 | if (Math.abs(pageScrollTop) < closestZeroDelta) { 240 | closestZeroDelta = Math.abs(pageScrollTop); 241 | targetScrollTop = pageHeight * pageIndex; 242 | } 243 | } 244 | 245 | this.scroller.__minDecelerationScrollTop = targetScrollTop; 246 | this.scroller.__maxDecelerationScrollTop = targetScrollTop; 247 | }; 248 | } 249 | 250 | export default ListView; 251 | -------------------------------------------------------------------------------- /src/ReactDOMComponentTree.js: -------------------------------------------------------------------------------- 1 | // from https://github.com/facebook/react/blob/87ae211ccd8d61796cfdef138d1e12fb7a74f85d/packages/shared/ReactTypeOfWork.js 2 | const HostComponent = 5; 3 | const HostText = 6; 4 | 5 | // adapted FROM: https://github.com/facebook/react/blob/master/packages/react-dom/src/client/ReactDOMComponentTree.js 6 | 7 | const randomKey = Math.random() 8 | .toString(36) 9 | .slice(2); 10 | const internalInstanceKey = "__reactInternalInstance$" + randomKey; 11 | 12 | /** 13 | * Given a DOM node, return the closest ReactDOMComponent or 14 | * ReactDOMTextComponent instance ancestor. 15 | */ 16 | export function getClosestInstanceFromNode(node) { 17 | if (node[internalInstanceKey]) { 18 | return node[internalInstanceKey]; 19 | } 20 | 21 | while (!node[internalInstanceKey]) { 22 | if (node.parentNode) { 23 | node = node.parentNode; 24 | } else { 25 | // Top of the tree. This node must not be part of a React tree (or is 26 | // unmounted, potentially). 27 | return null; 28 | } 29 | } 30 | 31 | const inst = node[internalInstanceKey]; 32 | if (inst.tag === HostComponent || inst.tag === HostText) { 33 | // In Fiber, this will always be the deepest root. 34 | return inst; 35 | } 36 | 37 | return null; 38 | } 39 | -------------------------------------------------------------------------------- /src/ReactDOMFrameScheduling.js: -------------------------------------------------------------------------------- 1 | // adapted FROM: https://github.com/facebook/react/blob/3019210df2b486416ed94d7b9becffaf254e81c4/src/renderers/shared/ReactDOMFrameScheduling.js 2 | 3 | "use strict"; 4 | 5 | // This is a built-in polyfill for requestIdleCallback. It works by scheduling 6 | // a requestAnimationFrame, storing the time for the start of the frame, then 7 | // scheduling a postMessage which gets scheduled after paint. Within the 8 | // postMessage handler do as much work as possible until time + frame rate. 9 | // By separating the idle call into a separate event tick we ensure that 10 | // layout, paint and other browser work is counted against the available time. 11 | // The frame rate is dynamically adjusted. 12 | 13 | const _typeof = 14 | typeof Symbol === "function" && typeof Symbol.iterator === "symbol" 15 | ? function(obj) { 16 | return typeof obj; 17 | } 18 | : function(obj) { 19 | return obj && 20 | typeof Symbol === "function" && 21 | obj.constructor === Symbol && 22 | obj !== Symbol.prototype 23 | ? "symbol" 24 | : typeof obj; 25 | }; 26 | 27 | const ExecutionEnvironment = require("fbjs/lib/ExecutionEnvironment"); 28 | 29 | const hasNativePerformanceNow = 30 | (typeof performance === "undefined" ? "undefined" : _typeof(performance)) === 31 | "object" && typeof performance.now === "function"; 32 | 33 | let now = void 0; 34 | if (hasNativePerformanceNow) { 35 | now = function now() { 36 | return performance.now(); 37 | }; 38 | } else { 39 | now = function now() { 40 | return Date.now(); 41 | }; 42 | } 43 | 44 | // TODO: There's no way to cancel, because Fiber doesn't atm. 45 | let rIC = void 0; 46 | 47 | if (!ExecutionEnvironment.canUseDOM) { 48 | rIC = function rIC(frameCallback) { 49 | setTimeout(function() { 50 | frameCallback({ 51 | timeRemaining: function timeRemaining() { 52 | return Infinity; 53 | } 54 | }); 55 | }); 56 | return 0; 57 | }; 58 | } else if (typeof requestIdleCallback !== "function") { 59 | // Polyfill requestIdleCallback. 60 | 61 | let scheduledRAFCallback = null; 62 | let scheduledRICCallback = null; 63 | 64 | let isIdleScheduled = false; 65 | let isAnimationFrameScheduled = false; 66 | 67 | let frameDeadline = 0; 68 | // We start out assuming that we run at 30fps but then the heuristic tracking 69 | // will adjust this value to a faster fps if we get more frequent animation 70 | // frames. 71 | let previousFrameTime = 33; 72 | let activeFrameTime = 33; 73 | 74 | let frameDeadlineObject; 75 | if (hasNativePerformanceNow) { 76 | frameDeadlineObject = { 77 | timeRemaining: function timeRemaining() { 78 | // We assume that if we have a performance timer that the rAF callback 79 | // gets a performance timer value. Not sure if this is always true. 80 | return frameDeadline - performance.now(); 81 | } 82 | }; 83 | } else { 84 | frameDeadlineObject = { 85 | timeRemaining: function timeRemaining() { 86 | // Fallback to Date.now() 87 | return frameDeadline - Date.now(); 88 | } 89 | }; 90 | } 91 | 92 | // We use the postMessage trick to defer idle work until after the repaint. 93 | const messageKey = 94 | "__reactIdleCallback$" + 95 | Math.random() 96 | .toString(36) 97 | .slice(2); 98 | const idleTick = function idleTick(event) { 99 | if (event.source !== window || event.data !== messageKey) { 100 | return; 101 | } 102 | isIdleScheduled = false; 103 | const callback = scheduledRICCallback; 104 | scheduledRICCallback = null; 105 | if (callback !== null) { 106 | callback(frameDeadlineObject); 107 | } 108 | }; 109 | // Assumes that we have addEventListener in this environment. Might need 110 | // something better for old IE. 111 | window.addEventListener("message", idleTick, false); 112 | 113 | const animationTick = function animationTick(rafTime) { 114 | isAnimationFrameScheduled = false; 115 | let nextFrameTime = rafTime - frameDeadline + activeFrameTime; 116 | if ( 117 | nextFrameTime < activeFrameTime && 118 | previousFrameTime < activeFrameTime 119 | ) { 120 | if (nextFrameTime < 8) { 121 | // Defensive coding. We don't support higher frame rates than 120hz. 122 | // If we get lower than that, it is probably a bug. 123 | nextFrameTime = 8; 124 | } 125 | // If one frame goes long, then the next one can be short to catch up. 126 | // If two frames are short in a row, then that's an indication that we 127 | // actually have a higher frame rate than what we're currently optimizing. 128 | // We adjust our heuristic dynamically accordingly. For example, if we're 129 | // running on 120hz display or 90hz VR display. 130 | // Take the max of the two in case one of them was an anomaly due to 131 | // missed frame deadlines. 132 | activeFrameTime = 133 | nextFrameTime < previousFrameTime ? previousFrameTime : nextFrameTime; 134 | } else { 135 | previousFrameTime = nextFrameTime; 136 | } 137 | frameDeadline = rafTime + activeFrameTime; 138 | if (!isIdleScheduled) { 139 | isIdleScheduled = true; 140 | window.postMessage(messageKey, "*"); 141 | } 142 | const callback = scheduledRAFCallback; 143 | scheduledRAFCallback = null; 144 | if (callback !== null) { 145 | callback(rafTime); 146 | } 147 | }; 148 | 149 | rIC = function rIC(callback) { 150 | // This assumes that we only schedule one callback at a time because that's 151 | // how Fiber uses it. 152 | scheduledRICCallback = callback; 153 | if (!isAnimationFrameScheduled) { 154 | // If rAF didn't already schedule one, we need to schedule a frame. 155 | // TODO: If this rAF doesn't materialize because the browser throttles, we 156 | // might want to still have setTimeout trigger rIC as a backup to ensure 157 | // that we keep performing work. 158 | isAnimationFrameScheduled = true; 159 | requestAnimationFrame(animationTick); 160 | } 161 | return 0; 162 | }; 163 | } else { 164 | rIC = requestIdleCallback; 165 | } 166 | 167 | exports.now = now; 168 | exports.rIC = rIC; 169 | -------------------------------------------------------------------------------- /src/RenderLayer.js: -------------------------------------------------------------------------------- 1 | import { zero } from "./FrameUtils"; 2 | import { invalidateBackingStore } from "./DrawingUtils"; 3 | import * as EventTypes from "./EventTypes"; 4 | 5 | function RenderLayer(component) { 6 | this.reset(component); 7 | } 8 | 9 | RenderLayer.prototype = { 10 | /** 11 | * Resets all the state on this RenderLayer so it can be added to a pool for re-use. 12 | * 13 | * @return {RenderLayer} 14 | */ 15 | reset: function(component) { 16 | if (this.backingStoreId) { 17 | invalidateBackingStore(this.backingStoreId); 18 | } 19 | 20 | for (const key in this) { 21 | if (key === "children" || key === "frame" || key === "component") 22 | continue; 23 | const value = this[key]; 24 | 25 | if (typeof value === "function") continue; 26 | this[key] = null; 27 | } 28 | 29 | if (this.children) { 30 | this.children.length = 0; 31 | } else { 32 | this.children = []; 33 | } 34 | 35 | if (this.frame) { 36 | this.frame.x = null; 37 | this.frame.y = null; 38 | this.frame.width = null; 39 | this.frame.height = null; 40 | } else { 41 | this.frame = zero(); 42 | } 43 | 44 | this.component = component; 45 | }, 46 | 47 | /** 48 | * Retrieve the root injection layer 49 | * 50 | * @return {RenderLayer} 51 | */ 52 | getRootLayer: function() { 53 | let root = this; 54 | while (root.parentLayer) { 55 | root = root.parentLayer; 56 | } 57 | return root; 58 | }, 59 | 60 | /** 61 | * RenderLayers are injected into a root owner layer whenever a Surface is 62 | * mounted. This is the integration point with React internals. 63 | * 64 | * @param {RenderLayer} parentLayer 65 | */ 66 | inject: function(parentLayer) { 67 | if (this.parentLayer && this.parentLayer !== parentLayer) { 68 | this.remove(); 69 | } 70 | if (!this.parentLayer) { 71 | parentLayer.addChild(this); 72 | } 73 | }, 74 | 75 | /** 76 | * Inject a layer before a reference layer 77 | * 78 | * @param {RenderLayer} parentLayer 79 | * @param {RenderLayer} referenceLayer 80 | */ 81 | injectBefore: function(parentLayer, beforeLayer) { 82 | this.remove(); 83 | const beforeIndex = parentLayer.children.indexOf(beforeLayer); 84 | parentLayer.children.splice(beforeIndex, 0, this); 85 | this.parentLayer = parentLayer; 86 | this.zIndex = beforeLayer.zIndex || 0; 87 | }, 88 | 89 | /** 90 | * Add a child to the render layer 91 | * 92 | * @param {RenderLayer} child 93 | */ 94 | addChild: function(child) { 95 | child.parentLayer = this; 96 | this.children.push(child); 97 | }, 98 | 99 | /** 100 | * Remove a layer from it's parent layer 101 | */ 102 | remove: function() { 103 | if (this.parentLayer) { 104 | this.parentLayer.children.splice( 105 | this.parentLayer.children.indexOf(this), 106 | 1 107 | ); 108 | 109 | this.parentLayer = null; 110 | } 111 | }, 112 | 113 | /** 114 | * Move a layer to top. 115 | */ 116 | moveToTop: function() { 117 | if ( 118 | this.parentLayer && 119 | this.parentLayer.children.length > 1 && 120 | this.parentLayer.children[0] !== this 121 | ) { 122 | this.parentLayer.children.splice( 123 | this.parentLayer.children.indexOf(this), 124 | 1 125 | ); 126 | 127 | this.parentLayer.children.unshift(this); 128 | } 129 | }, 130 | 131 | /** 132 | * Attach an event listener to a layer. Supported events are defined in 133 | * lib/EventTypes.js 134 | * 135 | * @param {String} type 136 | * @param {Function} callback 137 | * @param {?Object} callbackScope 138 | * @return {Function} invoke to unsubscribe the listener 139 | */ 140 | subscribe: function(type, callback, callbackScope) { 141 | // This is the integration point with React, called from LayerMixin.putEventListener(). 142 | // Enforce that only a single callbcak can be assigned per event type. 143 | for (const eventType in EventTypes) { 144 | if (EventTypes[eventType] === type) { 145 | this[eventType] = callback; 146 | } 147 | } 148 | 149 | // Return a function that can be called to unsubscribe from the event. 150 | return this.removeEventListener.bind(this, type, callback, callbackScope); 151 | }, 152 | 153 | /** 154 | * @param {String} type 155 | */ 156 | destroyEventListeners: function() { 157 | for (const eventType in EventTypes) { 158 | if (this[eventType]) { 159 | delete this[eventType]; 160 | } 161 | } 162 | }, 163 | 164 | /** 165 | * @param {String} type 166 | * @param {Function} callback 167 | * @param {?Object} callbackScope 168 | */ 169 | removeEventListener: function(type, callback, callbackScope) { 170 | const listeners = this.eventListeners[type]; 171 | let listener; 172 | if (listeners) { 173 | for (let index = 0, len = listeners.length; index < len; index++) { 174 | listener = listeners[index]; 175 | if ( 176 | listener.callback === callback && 177 | listener.callbackScope === callbackScope 178 | ) { 179 | listeners.splice(index, 1); 180 | break; 181 | } 182 | } 183 | } 184 | }, 185 | 186 | /** 187 | * Translate a layer's frame 188 | * 189 | * @param {Number} x 190 | * @param {Number} y 191 | */ 192 | translate: function(x, y) { 193 | if (this.frame) { 194 | this.frame.x += x; 195 | this.frame.y += y; 196 | } 197 | 198 | if (this.clipRect) { 199 | this.clipRect.x += x; 200 | this.clipRect.y += y; 201 | } 202 | 203 | if (this.children) { 204 | this.children.forEach(function(child) { 205 | child.translate(x, y); 206 | }); 207 | } 208 | }, 209 | 210 | /** 211 | * Layers should call this method when they need to be redrawn. Note the 212 | * difference here between `invalidateBackingStore`: updates that don't 213 | * trigger layout should prefer `invalidateLayout`. For instance, an image 214 | * component that is animating alpha level after the image loads would 215 | * call `invalidateBackingStore` once after the image loads, and at each 216 | * step in the animation would then call `invalidateRect`. 217 | * 218 | * @param {?Frame} frame Optional, if not passed the entire layer's frame 219 | * will be invalidated. 220 | */ 221 | invalidateLayout: function() { 222 | // Bubble all the way to the root layer. 223 | this.getRootLayer().draw(); 224 | }, 225 | 226 | /** 227 | * Layers should call this method when their backing needs to be 228 | * redrawn. For instance, an image component would call this once after the 229 | * image loads. 230 | */ 231 | invalidateBackingStore: function() { 232 | if (this.backingStoreId) { 233 | invalidateBackingStore(this.backingStoreId); 234 | } 235 | this.invalidateLayout(); 236 | }, 237 | 238 | /** 239 | * Only the root owning layer should implement this function. 240 | */ 241 | draw: function() { 242 | // Placeholer 243 | } 244 | }; 245 | 246 | export default RenderLayer; 247 | -------------------------------------------------------------------------------- /src/Surface.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import React from "react"; 4 | import PropTypes from "prop-types"; 5 | import RenderLayer from "./RenderLayer"; 6 | import { make } from "./FrameUtils"; 7 | import { drawRenderLayer } from "./DrawingUtils"; 8 | import hitTest from "./hitTest"; 9 | import layoutNode from "./layoutNode"; 10 | 11 | const MOUSE_CLICK_DURATION_MS = 300; 12 | 13 | /** 14 | * Surface is a standard React component and acts as the main drawing canvas. 15 | * ReactCanvas components cannot be rendered outside a Surface. 16 | */ 17 | class Surface extends React.Component { 18 | displayName = "Surface"; 19 | 20 | static propTypes = { 21 | className: PropTypes.string, 22 | id: PropTypes.string, 23 | top: PropTypes.number.isRequired, 24 | left: PropTypes.number.isRequired, 25 | width: PropTypes.number.isRequired, 26 | height: PropTypes.number.isRequired, 27 | scale: PropTypes.number.isRequired, 28 | enableCSSLayout: PropTypes.bool, 29 | children: PropTypes.object, 30 | style: PropTypes.object, 31 | canvas: PropTypes.object 32 | }; 33 | 34 | static defaultProps = { 35 | scale: window.devicePixelRatio || 1 36 | }; 37 | 38 | static canvasRenderer = null; 39 | 40 | setCanvasRef = canvas => { 41 | this.canvas = canvas; 42 | }; 43 | 44 | constructor(props) { 45 | super(props); 46 | 47 | if (props.canvas) { 48 | this.setCanvasRef(props.canvas); 49 | } 50 | } 51 | 52 | componentDidMount = () => { 53 | // Prepare the for drawing. 54 | this.scale(); 55 | 56 | // ContainerMixin expects `this.node` to be set prior to mounting children. 57 | // `this.node` is injected into child components and represents the current 58 | // render tree. 59 | this.node = new RenderLayer(); 60 | this.node.frame = make( 61 | this.props.left, 62 | this.props.top, 63 | this.props.width, 64 | this.props.height 65 | ); 66 | this.node.draw = this.batchedTick; 67 | 68 | this.mountNode = Surface.canvasRenderer.createContainer(this); 69 | Surface.canvasRenderer.updateContainer( 70 | this.props.children, 71 | this.mountNode, 72 | this 73 | ); 74 | 75 | // Execute initial draw on mount. 76 | this.node.draw(); 77 | }; 78 | 79 | componentWillUnmount = () => { 80 | Surface.canvasRenderer.updateContainer(null, this.mountNode, this); 81 | }; 82 | 83 | componentDidUpdate = prevProps => { 84 | // Re-scale the when changing size. 85 | if ( 86 | prevProps.width !== this.props.width || 87 | prevProps.height !== this.props.height 88 | ) { 89 | this.scale(); 90 | } 91 | 92 | Surface.canvasRenderer.updateContainer( 93 | this.props.children, 94 | this.mountNode, 95 | this 96 | ); 97 | 98 | // Redraw updated render tree to . 99 | if (this.node) { 100 | this.node.draw(); 101 | } 102 | }; 103 | 104 | render() { 105 | if (this.props.canvas) { 106 | return null; 107 | } 108 | 109 | // Scale the drawing area to match DPI. 110 | const width = this.props.width * this.props.scale; 111 | const height = this.props.height * this.props.scale; 112 | let style = {}; 113 | 114 | if (this.props.style) { 115 | style = Object.assign({}, this.props.style); 116 | } 117 | 118 | if (typeof this.props.width !== "undefined") { 119 | style.width = this.props.width; 120 | } 121 | 122 | if (typeof this.props.height !== "undefined") { 123 | style.height = this.props.height; 124 | } 125 | 126 | return React.createElement("canvas", { 127 | ref: this.setCanvasRef, 128 | className: this.props.className, 129 | id: this.props.id, 130 | width: width, 131 | height: height, 132 | style: style, 133 | onTouchStart: this.handleTouchStart, 134 | onTouchMove: this.handleTouchMove, 135 | onTouchEnd: this.handleTouchEnd, 136 | onTouchCancel: this.handleTouchEnd, 137 | onMouseDown: this.handleMouseEvent, 138 | onMouseUp: this.handleMouseEvent, 139 | onMouseMove: this.handleMouseEvent, 140 | onMouseOver: this.handleMouseEvent, 141 | onMouseOut: this.handleMouseEvent, 142 | onContextMenu: this.handleContextMenu, 143 | onClick: this.handleMouseEvent, 144 | onDoubleClick: this.handleMouseEvent 145 | }); 146 | } 147 | 148 | // Drawing 149 | // ======= 150 | getLayer = () => this.node; 151 | 152 | getContext = () => { 153 | return this.canvas.getContext("2d"); 154 | }; 155 | 156 | scale = () => { 157 | this.getContext().scale(this.props.scale, this.props.scale); 158 | }; 159 | 160 | batchedTick = () => { 161 | if (this._frameReady === false) { 162 | this._pendingTick = true; 163 | return; 164 | } 165 | this.tick(); 166 | }; 167 | 168 | tick = () => { 169 | // Block updates until next animation frame. 170 | this._frameReady = false; 171 | this.clear(); 172 | this.draw(); 173 | requestAnimationFrame(this.afterTick); 174 | }; 175 | 176 | afterTick = () => { 177 | // Execute pending draw that may have been scheduled during previous frame 178 | this._frameReady = true; 179 | if (this._pendingTick) { 180 | this._pendingTick = false; 181 | this.batchedTick(); 182 | } 183 | }; 184 | 185 | clear = () => { 186 | this.getContext().clearRect(0, 0, this.props.width, this.props.height); 187 | }; 188 | 189 | draw = () => { 190 | if (this.node) { 191 | if (this.props.enableCSSLayout) { 192 | layoutNode(this.node); 193 | } 194 | drawRenderLayer(this.getContext(), this.node); 195 | } 196 | }; 197 | 198 | // Events 199 | // ====== 200 | 201 | hitTest = e => { 202 | const hitTarget = hitTest(e, this.node, this.canvas); 203 | if (hitTarget) { 204 | hitTarget[hitTest.getHitHandle(e.type)](e); 205 | } 206 | }; 207 | 208 | handleTouchStart = e => { 209 | const hitTarget = hitTest(e, this.node, this.canvas); 210 | 211 | let touch; 212 | if (hitTarget) { 213 | // On touchstart: capture the current hit target for the given touch. 214 | this._touches = this._touches || {}; 215 | 216 | for (let i = 0, len = e.touches.length; i < len; i++) { 217 | touch = e.touches[i]; 218 | this._touches[touch.identifier] = hitTarget; 219 | } 220 | hitTarget[hitTest.getHitHandle(e.type)](e); 221 | } 222 | }; 223 | 224 | handleTouchMove = e => { 225 | this.hitTest(e); 226 | }; 227 | 228 | handleTouchEnd = e => { 229 | // touchend events do not generate a pageX/pageY so we rely 230 | // on the currently captured touch targets. 231 | if (!this._touches) { 232 | return; 233 | } 234 | 235 | let hitTarget; 236 | const hitHandle = hitTest.getHitHandle(e.type); 237 | for (let i = 0, len = e.changedTouches.length; i < len; i++) { 238 | hitTarget = this._touches[e.changedTouches[i].identifier]; 239 | if (hitTarget && hitTarget[hitHandle]) { 240 | hitTarget[hitHandle](e); 241 | } 242 | delete this._touches[e.changedTouches[i].identifier]; 243 | } 244 | }; 245 | 246 | handleMouseEvent = e => { 247 | if (e.type === "mousedown") { 248 | // Keep track of initial mouse down info to detect a proper click. 249 | this._lastMouseDownTimestamp = e.timeStamp; 250 | this._lastMouseDownPosition = [e.pageX, e.pageY]; 251 | this._draggedSinceMouseDown = false; 252 | } else if ( 253 | e.type === "click" || 254 | e.type === "dblclick" || 255 | e.type === "mouseout" 256 | ) { 257 | if (e.type === "click" || e.type === "dblclick") { 258 | // Forward the click if the mouse did not travel and it was a short enough duration. 259 | if ( 260 | this._draggedSinceMouseDown || 261 | !this._lastMouseDownTimestamp || 262 | e.timeStamp - this._lastMouseDownTimestamp > MOUSE_CLICK_DURATION_MS 263 | ) { 264 | return; 265 | } 266 | } 267 | 268 | this._lastMouseDownTimestamp = null; 269 | this._lastMouseDownPosition = null; 270 | this._draggedSinceMouseDown = false; 271 | } else if ( 272 | e.type === "mousemove" && 273 | !this._draggedSinceMouseDown && 274 | this._lastMouseDownPosition 275 | ) { 276 | // Detect dragging 277 | this._draggedSinceMouseDown = 278 | e.pageX !== this._lastMouseDownPosition[0] || 279 | e.pageY !== this._lastMouseDownPosition[1]; 280 | } 281 | 282 | let hitTarget = hitTest(e, this.node, this.canvas); 283 | 284 | // For mouseout events, we need to save the last target so we fire it again to that target 285 | // since we won't have a hit (since the mouse has left the canvas.) 286 | if (e.type === "mouseout") { 287 | hitTarget = this._lastHitTarget; 288 | } else { 289 | this._lastHitTarget = hitTarget; 290 | } 291 | 292 | if (hitTarget) { 293 | const handler = hitTarget[hitTest.getHitHandle(e.type)]; 294 | 295 | if (handler) { 296 | handler(e); 297 | } 298 | } 299 | }; 300 | 301 | handleContextMenu = e => { 302 | this.hitTest(e); 303 | }; 304 | } 305 | 306 | export default Surface; 307 | -------------------------------------------------------------------------------- /src/Text.js: -------------------------------------------------------------------------------- 1 | import CanvasComponent from "./CanvasComponent"; 2 | 3 | function childrenAsString(children) { 4 | if (!children) { 5 | return ""; 6 | } 7 | if (typeof children === "string") { 8 | return children; 9 | } 10 | if (children.length) { 11 | return children.join("\n"); 12 | } 13 | return ""; 14 | } 15 | 16 | function textArraysEqual(a, b) { 17 | if (typeof a !== typeof b || a.length !== b.length) return false; 18 | 19 | for (let i = 0; i < a.length; i++) { 20 | if (a[i] !== b[i]) return false; 21 | } 22 | 23 | return true; 24 | } 25 | 26 | const LAYER_TYPE = "text"; 27 | 28 | class Text extends CanvasComponent { 29 | applyLayerProps = (prevProps, props) => { 30 | const style = props && props.style ? props.style : {}; 31 | const layer = this.node; 32 | 33 | if (layer.type !== LAYER_TYPE) { 34 | layer.type = LAYER_TYPE; 35 | } 36 | 37 | if ( 38 | layer.text === null || 39 | !textArraysEqual(prevProps.children, props.children) 40 | ) { 41 | layer.text = childrenAsString(props.children); 42 | } 43 | 44 | if (layer.color !== style.color) layer.color = style.color; 45 | 46 | if (layer.fontFace !== style.fontFace) layer.fontFace = style.fontFace; 47 | 48 | if (layer.fontSize !== style.fontSize) layer.fontSize = style.fontSize; 49 | 50 | if (layer.lineHeight !== style.lineHeight) 51 | layer.lineHeight = style.lineHeight; 52 | 53 | if (layer.textAlign !== style.textAlign) layer.textAlign = style.textAlign; 54 | 55 | this.applyCommonLayerProps(prevProps, props); 56 | }; 57 | } 58 | 59 | export default Text; 60 | -------------------------------------------------------------------------------- /src/clamp.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Clamp a number between a minimum and maximum value. 3 | * @param {Number} number 4 | * @param {Number} min 5 | * @param {Number} max 6 | * @return {Number} 7 | */ 8 | export default function(number, min, max) { 9 | return Math.min(Math.max(number, min), max); 10 | } 11 | -------------------------------------------------------------------------------- /src/hitTest.js: -------------------------------------------------------------------------------- 1 | import { make, clone, inset, intersects } from "./FrameUtils"; 2 | import * as EventTypes from "./EventTypes"; 3 | 4 | /** 5 | * @private 6 | */ 7 | function sortByZIndexDescending(layer, otherLayer) { 8 | return (otherLayer.zIndex || 0) - (layer.zIndex || 0); 9 | } 10 | 11 | /** 12 | * @private 13 | */ 14 | function getHitHandle(type) { 15 | let hitHandle; 16 | for (const tryHandle in EventTypes) { 17 | if (EventTypes[tryHandle] === type) { 18 | hitHandle = tryHandle; 19 | break; 20 | } 21 | } 22 | return hitHandle; 23 | } 24 | 25 | /** 26 | * @private 27 | */ 28 | function getLayerAtPoint(root, type, point, tx, ty) { 29 | let layer = null; 30 | const hitHandle = getHitHandle(type); 31 | let sortedChildren; 32 | let hitFrame = clone(root.frame); 33 | 34 | // Early bail for non-visible layers 35 | if (typeof root.alpha === "number" && root.alpha < 0.01) { 36 | return null; 37 | } 38 | 39 | // Child-first search 40 | if (root.children) { 41 | sortedChildren = root.children 42 | .slice() 43 | .reverse() 44 | .sort(sortByZIndexDescending); 45 | for (let i = 0, len = sortedChildren.length; i < len; i++) { 46 | layer = getLayerAtPoint( 47 | sortedChildren[i], 48 | type, 49 | point, 50 | tx + (root.translateX || 0), 51 | ty + (root.translateY || 0) 52 | ); 53 | if (layer) { 54 | break; 55 | } 56 | } 57 | } 58 | 59 | // Check for hit outsets 60 | if (root.hitOutsets) { 61 | hitFrame = inset( 62 | clone(hitFrame), 63 | -root.hitOutsets[0], 64 | -root.hitOutsets[1], 65 | -root.hitOutsets[2], 66 | -root.hitOutsets[3] 67 | ); 68 | } 69 | 70 | // Check for x/y translation 71 | if (tx) { 72 | hitFrame.x += tx; 73 | } 74 | 75 | if (ty) { 76 | hitFrame.y += ty; 77 | } 78 | 79 | // No child layer at the given point. Try the parent layer. 80 | if (!layer && root[hitHandle] && intersects(hitFrame, point)) { 81 | layer = root; 82 | } 83 | 84 | return layer; 85 | } 86 | 87 | /** 88 | * RenderLayer hit testing 89 | * 90 | * @param {Event} e 91 | * @param {RenderLayer} rootLayer 92 | * @param {?HTMLElement} rootNode 93 | * @return {RenderLayer} 94 | */ 95 | function hitTest(e, rootLayer, rootNode) { 96 | const touch = e.touches ? e.touches[0] : e; 97 | let touchX = touch.pageX; 98 | let touchY = touch.pageY; 99 | let rootNodeBox; 100 | if (rootNode) { 101 | rootNodeBox = rootNode.getBoundingClientRect(); 102 | touchX -= rootNodeBox.left; 103 | touchY -= rootNodeBox.top; 104 | } 105 | 106 | touchY = touchY - window.pageYOffset; 107 | touchX = touchX - window.pageXOffset; 108 | 109 | return getLayerAtPoint( 110 | rootLayer, 111 | e.type, 112 | make(touchX, touchY, 1, 1), 113 | rootLayer.translateX || 0, 114 | rootLayer.translateY || 0 115 | ); 116 | } 117 | 118 | hitTest.getHitHandle = getHitHandle; 119 | export default hitTest; 120 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Surface from "./Surface"; 2 | import Core from "./Core"; 3 | import Image from "./Image"; 4 | import ListView from "./ListView"; 5 | import FontFace from "./FontFace"; 6 | import FrameUtils from "./FrameUtils"; 7 | import measureText from "./measureText"; 8 | import CanvasComponent from "./CanvasComponent"; 9 | import CanvasRenderer from "./CanvasRenderer"; 10 | import { registerLayerType } from "./DrawingUtils"; 11 | 12 | Surface.canvasRenderer = CanvasRenderer; 13 | 14 | const registerCustomComponent = function(name, applyProps, drawFunction) { 15 | const layerType = name.toLowerCase(); 16 | 17 | registerLayerType(layerType, drawFunction); 18 | 19 | const klass = class extends CanvasComponent { 20 | displayName = name; 21 | 22 | applyLayerProps = (prevProps, props) => { 23 | const style = props && props.style ? props.style : {}; 24 | const layer = this.node; 25 | layer.type = layerType; 26 | applyProps(layer, style, prevProps, props); 27 | this.applyCommonLayerProps(prevProps, props); 28 | }; 29 | }; 30 | 31 | CanvasRenderer.registerComponentConstructor(name, klass); 32 | 33 | return name; 34 | }; 35 | 36 | const ReactCanvas = { 37 | ...Core, 38 | Surface, 39 | Image, 40 | ListView, 41 | FontFace, 42 | FrameUtils, 43 | measureText, 44 | registerCustomComponent 45 | }; 46 | 47 | export default ReactCanvas; 48 | -------------------------------------------------------------------------------- /src/layoutNode.js: -------------------------------------------------------------------------------- 1 | import computeLayout from "css-layout"; 2 | import emptyObject from "fbjs/lib/emptyObject"; 3 | 4 | function createNode(layer) { 5 | return { 6 | layer: layer, 7 | layout: { 8 | width: undefined, // computeLayout will mutate 9 | height: undefined, // computeLayout will mutate 10 | top: 0, 11 | left: 0 12 | }, 13 | style: layer._originalStyle || emptyObject, 14 | // TODO no need to layout children that have non-dirty backing store 15 | children: (layer.children || []).map(createNode) 16 | }; 17 | } 18 | 19 | function walkNode(node, parentLeft, parentTop) { 20 | node.layer.frame.x = node.layout.left + (parentLeft || 0); 21 | node.layer.frame.y = node.layout.top + (parentTop || 0); 22 | node.layer.frame.width = node.layout.width; 23 | node.layer.frame.height = node.layout.height; 24 | if (node.children && node.children.length > 0) { 25 | node.children.forEach(function(child) { 26 | walkNode(child, node.layer.frame.x, node.layer.frame.y); 27 | }); 28 | } 29 | } 30 | 31 | /** 32 | * This computes the CSS layout for a RenderLayer tree and mutates the frame 33 | * objects at each node. 34 | * 35 | * @param {Renderlayer} root 36 | * @return {Object} 37 | */ 38 | function layoutNode(root) { 39 | const rootNode = createNode(root); 40 | computeLayout(rootNode); 41 | walkNode(rootNode); 42 | return rootNode; 43 | } 44 | 45 | export default layoutNode; 46 | -------------------------------------------------------------------------------- /src/measureText.js: -------------------------------------------------------------------------------- 1 | import { isFontLoaded } from "./FontUtils"; 2 | import LineBreaker from "@craigmorton/linebreak"; 3 | import MultiKeyCache from "multi-key-cache"; 4 | 5 | const canvas = document.createElement("canvas"); 6 | const ctx = canvas.getContext("2d"); 7 | 8 | const _cache = new MultiKeyCache(); 9 | const _zeroMetrics = { 10 | width: 0, 11 | height: 0, 12 | lines: [] 13 | }; 14 | 15 | /** 16 | * Given a string of text, available width, and font return the measured width 17 | * and height. 18 | * @param {String} text The input string 19 | * @param {Number} width The available width 20 | * @param {FontFace} fontFace The FontFace to use 21 | * @param {Number} fontSize The font size in CSS pixels 22 | * @param {Number} lineHeight The line height in CSS pixels 23 | * @return {Object} Measured text size with `width` and `height` members. 24 | */ 25 | export default function measureText( 26 | text, 27 | width, 28 | fontFace, 29 | fontSize, 30 | lineHeight 31 | ) { 32 | const cacheKey = [text, width, fontFace.id, fontSize, lineHeight]; 33 | const cached = _cache.get(cacheKey); 34 | if (cached) { 35 | return cached; 36 | } 37 | 38 | // Bail and return zero unless we're sure the font is ready. 39 | if (!isFontLoaded(fontFace)) { 40 | return _zeroMetrics; 41 | } 42 | 43 | const measuredSize = {}; 44 | let textMetrics; 45 | let lastMeasuredWidth; 46 | let tryLine; 47 | let currentLine; 48 | let breaker; 49 | let bk; 50 | let lastBreak; 51 | 52 | ctx.font = 53 | fontFace.attributes.style + 54 | " normal " + 55 | fontFace.attributes.weight + 56 | " " + 57 | fontSize + 58 | "px " + 59 | fontFace.family; 60 | textMetrics = ctx.measureText(text); 61 | 62 | measuredSize.width = textMetrics.width; 63 | measuredSize.height = lineHeight; 64 | measuredSize.lines = []; 65 | 66 | if (measuredSize.width <= width) { 67 | // The entire text string fits. 68 | measuredSize.lines.push({ width: measuredSize.width, text: text }); 69 | } else { 70 | // Break into multiple lines. 71 | measuredSize.width = width; 72 | currentLine = ""; 73 | breaker = new LineBreaker(text); 74 | 75 | while ((bk = breaker.nextBreak())) { 76 | const word = text.slice(lastBreak ? lastBreak.position : 0, bk.position); 77 | 78 | tryLine = currentLine + word; 79 | textMetrics = ctx.measureText(tryLine); 80 | if (textMetrics.width > width || (lastBreak && lastBreak.required)) { 81 | measuredSize.height += lineHeight; 82 | measuredSize.lines.push({ 83 | width: lastMeasuredWidth, 84 | text: currentLine.trim() 85 | }); 86 | currentLine = word; 87 | lastMeasuredWidth = ctx.measureText(currentLine.trim()).width; 88 | } else { 89 | currentLine = tryLine; 90 | lastMeasuredWidth = textMetrics.width; 91 | } 92 | 93 | lastBreak = bk; 94 | } 95 | 96 | currentLine = currentLine.trim(); 97 | if (currentLine.length > 0) { 98 | textMetrics = ctx.measureText(currentLine); 99 | measuredSize.lines.push({ width: textMetrics, text: currentLine }); 100 | } 101 | } 102 | 103 | _cache.set(cacheKey, measuredSize); 104 | 105 | return measuredSize; 106 | } 107 | -------------------------------------------------------------------------------- /stories/canvasStory.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import { Gradient, Text, Group, Image, Surface } from "../index"; 4 | 5 | storiesOf("Gradient", module) 6 | .add("transparent-grey", () => { 7 | const props = { size: { width: 80, height: 80 } }; 8 | return ( 9 |
10 | 16 | 28 | 29 |
30 | ); 31 | }) 32 | .add("blue-green", () => { 33 | const props = { size: { width: 80, height: 80 } }; 34 | return ( 35 |
36 | 42 | 54 | 55 |
56 | ); 57 | }); 58 | 59 | storiesOf("Text", module).add("hello-world", () => { 60 | const props = { size: { width: 400, height: 400 } }; 61 | return ( 62 |
63 | 69 | 70 | 78 | Hello World 79 | 80 | 89 | Hello World 2 90 | 91 | 92 | 99 | 100 | 101 |
102 | ); 103 | }); 104 | 105 | storiesOf("Image", module).add("hello-world", () => { 106 | const props = { size: { width: 400, height: 400 } }; 107 | return ( 108 |
109 | 115 | 124 | 125 |
126 | ); 127 | }); 128 | -------------------------------------------------------------------------------- /stories/components/Page.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import ReactCanvas from "../../src/index"; 5 | const { Group, Image, Text, FontFace, measureText } = ReactCanvas; 6 | 7 | const CONTENT_INSET = 14; 8 | const TEXT_SCROLL_SPEED_MULTIPLIER = 0.6; 9 | const TEXT_ALPHA_SPEED_OUT_MULTIPLIER = 1.25; 10 | const TEXT_ALPHA_SPEED_IN_MULTIPLIER = 2.6; 11 | const IMAGE_LAYER_INDEX = 2; 12 | const TEXT_LAYER_INDEX = 1; 13 | 14 | class Page extends React.Component { 15 | static propTypes = { 16 | width: PropTypes.number, 17 | height: PropTypes.number, 18 | article: PropTypes.object, 19 | scrollTop: PropTypes.number 20 | }; 21 | 22 | constructor(props) { 23 | super(); 24 | 25 | // Pre-compute headline/excerpt text dimensions. 26 | const article = props.article; 27 | const maxWidth = props.width - 2 * CONTENT_INSET; 28 | const titleStyle = this.getTitleStyle(props); 29 | const excerptStyle = this.getExcerptStyle(props); 30 | 31 | this.titleMetrics = measureText( 32 | article.title, 33 | maxWidth, 34 | titleStyle.fontFace, 35 | titleStyle.fontSize, 36 | titleStyle.lineHeight 37 | ); 38 | this.excerptMetrics = measureText( 39 | article.excerpt, 40 | maxWidth, 41 | excerptStyle.fontFace, 42 | excerptStyle.fontSize, 43 | excerptStyle.lineHeight 44 | ); 45 | } 46 | 47 | render() { 48 | const groupStyle = this.getGroupStyle(); 49 | const imageStyle = this.getImageStyle(); 50 | const titleStyle = this.getTitleStyle(this.props); 51 | const excerptStyle = this.getExcerptStyle(this.props); 52 | 53 | // Layout title and excerpt below image. 54 | titleStyle.height = this.titleMetrics.height; 55 | excerptStyle.top = titleStyle.top + titleStyle.height + CONTENT_INSET; 56 | excerptStyle.height = this.props.height - excerptStyle.top - CONTENT_INSET; 57 | 58 | return ( 59 | 60 | 66 | 67 | {this.props.article.title} 68 | {this.props.article.excerpt} 69 | 70 | 71 | ); 72 | } 73 | 74 | // Styles 75 | // ====== 76 | 77 | getGroupStyle = () => { 78 | return { 79 | top: 0, 80 | left: 0, 81 | width: this.props.width, 82 | height: this.props.height 83 | }; 84 | }; 85 | 86 | getImageHeight = props => { 87 | return Math.round(props.height * 0.5); 88 | }; 89 | 90 | getImageStyle = () => { 91 | return { 92 | top: 0, 93 | left: 0, 94 | width: this.props.width, 95 | height: this.getImageHeight(this.props), 96 | backgroundColor: "#eee", 97 | zIndex: IMAGE_LAYER_INDEX 98 | }; 99 | }; 100 | 101 | getTitleStyle = props => { 102 | return { 103 | top: this.getImageHeight(props) + CONTENT_INSET, 104 | left: CONTENT_INSET, 105 | width: props.width - 2 * CONTENT_INSET, 106 | fontSize: 22, 107 | lineHeight: 30, 108 | fontFace: FontFace("Avenir Next Condensed, Helvetica, sans-serif", null, { 109 | weight: 500 110 | }) 111 | }; 112 | }; 113 | 114 | getExcerptStyle = props => { 115 | return { 116 | left: CONTENT_INSET, 117 | width: props.width - 2 * CONTENT_INSET, 118 | fontFace: FontFace("Georgia, serif"), 119 | fontSize: 15, 120 | lineHeight: 23 121 | }; 122 | }; 123 | 124 | getTextGroupStyle = () => { 125 | const imageHeight = this.getImageHeight(this.props); 126 | let translateY = 0; 127 | const alphaMultiplier = 128 | this.props.scrollTop <= 0 129 | ? -TEXT_ALPHA_SPEED_OUT_MULTIPLIER 130 | : TEXT_ALPHA_SPEED_IN_MULTIPLIER; 131 | let alpha = 1 - this.props.scrollTop / this.props.height * alphaMultiplier; 132 | alpha = Math.min(Math.max(alpha, 0), 1); 133 | translateY = -this.props.scrollTop * TEXT_SCROLL_SPEED_MULTIPLIER; 134 | 135 | return { 136 | width: this.props.width, 137 | height: this.props.height - imageHeight, 138 | top: imageHeight, 139 | left: 0, 140 | alpha: alpha, 141 | translateY: translateY, 142 | zIndex: TEXT_LAYER_INDEX 143 | }; 144 | }; 145 | } 146 | 147 | export default Page; 148 | -------------------------------------------------------------------------------- /stories/csslayout.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | import ReactCanvas from "../src/index"; 4 | const { FontFace, Text, Group, Image, Surface } = ReactCanvas; 5 | 6 | class App extends React.Component { 7 | componentDidMount() { 8 | window.addEventListener("resize", this.handleResize, true); 9 | } 10 | 11 | componentWillUnmount() { 12 | this._unmounted = true; 13 | } 14 | 15 | render() { 16 | const size = this.getSize(); 17 | 18 | return ( 19 | 26 | 27 | Professor PuddinPop 28 | 29 | 34 | 35 | 36 | With these words the Witch fell down in a brown, melted, shapeless 37 | mass and began to spread over the clean boards of the kitchen floor. 38 | Seeing that she had really melted away to nothing, Dorothy drew 39 | another bucket of water and threw it over the mess. She then swept 40 | it all out the door. After picking out the silver shoe, which was 41 | all that was left of the old woman, she cleaned and dried it with a 42 | cloth, and put it on her foot again. Then, being at last free to do 43 | as she chose, she ran out to the courtyard to tell the Lion that the 44 | Wicked Witch of the West had come to an end, and that they were no 45 | longer prisoners in a strange land. 46 | 47 | 48 | 49 | ); 50 | } 51 | 52 | // Styles 53 | // ====== 54 | 55 | getSize = () => { 56 | return { width: window.innerWidth - 30, height: window.innerHeight - 30 }; 57 | }; 58 | 59 | getPageStyle = () => { 60 | const size = this.getSize(); 61 | return { 62 | position: "relative", 63 | padding: 14, 64 | width: size.width, 65 | height: size.height, 66 | backgroundColor: "#f7f7f7", 67 | flexDirection: "column" 68 | }; 69 | }; 70 | 71 | getImageGroupStyle = () => { 72 | return { 73 | position: "relative", 74 | flex: 1, 75 | backgroundColor: "#eee" 76 | }; 77 | }; 78 | 79 | getImageStyle = () => { 80 | return { 81 | position: "absolute", 82 | left: 0, 83 | top: 0, 84 | right: 0, 85 | bottom: 0 86 | }; 87 | }; 88 | 89 | getTitleStyle = () => { 90 | return { 91 | fontFace: FontFace("Georgia"), 92 | fontSize: 22, 93 | lineHeight: 28, 94 | height: 28, 95 | marginBottom: 10, 96 | color: "#333", 97 | textAlign: "center" 98 | }; 99 | }; 100 | 101 | getExcerptStyle = () => { 102 | return { 103 | fontFace: FontFace("Georgia"), 104 | fontSize: 17, 105 | lineHeight: 25, 106 | marginTop: 15, 107 | flex: 1, 108 | color: "#333" 109 | }; 110 | }; 111 | 112 | // Events 113 | // ====== 114 | 115 | handleResize = () => { 116 | if (!this._unmounted) { 117 | this.forceUpdate(); 118 | } 119 | }; 120 | } 121 | 122 | storiesOf("CSS", module).add("test-css", () => { 123 | return ( 124 |
125 | 126 |
127 | ); 128 | }); 129 | -------------------------------------------------------------------------------- /stories/customDrawStory.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { storiesOf } from "@storybook/react"; 3 | 4 | import ReactCanvas from "../src/index"; 5 | 6 | const { Surface } = ReactCanvas; 7 | 8 | const circleDraw = function(ctx, layer) { 9 | const x = layer.frame.x; 10 | const y = layer.frame.y; 11 | const width = layer.frame.width; 12 | const height = layer.frame.height; 13 | const centerX = x + width / 2; 14 | const centerY = y + height / 2; 15 | 16 | const fillColor = layer.backgroundColor || "#FFF"; 17 | const strokeColor = layer.borderColor || "#FFF"; 18 | const strokeWidth = layer.borderWidth || 0; 19 | 20 | const shadowColor = layer.shadowColor || 0; 21 | const shadowOffsetX = layer.shadowOffsetX || 0; 22 | const shadowOffsetY = layer.shadowOffsetY || 0; 23 | const shadowBlur = layer.shadowBlur || 0; 24 | 25 | const radius = Math.min(width / 2, height / 2) - Math.ceil(strokeWidth / 2); 26 | 27 | ctx.beginPath(); 28 | ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI, false); 29 | if (shadowOffsetX || shadowOffsetY) { 30 | ctx.shadowColor = shadowColor; 31 | ctx.shadowBlur = shadowBlur; 32 | ctx.shadowOffsetX = shadowOffsetX; 33 | ctx.shadowOffsetY = shadowOffsetY; 34 | } 35 | 36 | ctx.fillStyle = fillColor; 37 | ctx.fill(); 38 | if (strokeWidth > 0) { 39 | ctx.lineWidth = strokeWidth; 40 | ctx.strokeStyle = strokeColor; 41 | ctx.stroke(); 42 | } 43 | }; 44 | 45 | const circleApplyProps = (layer, style /*, prevProps, props*/) => { 46 | layer.shadowColor = style.shadowColor || 0; 47 | layer.shadowOffsetX = style.shadowOffsetX || 0; 48 | layer.shadowOffsetY = style.shadowOffsetY || 0; 49 | layer.shadowBlur = style.shadowBlur || 0; 50 | }; 51 | 52 | const Circle = ReactCanvas.registerCustomComponent( 53 | "Circle", 54 | circleApplyProps, 55 | circleDraw 56 | ); 57 | 58 | class App extends React.Component { 59 | render() { 60 | return ( 61 | 62 | 78 | 79 | ); 80 | } 81 | } 82 | 83 | storiesOf("CustomDraw", module).add("green-circle", () => { 84 | return ( 85 |
86 | 87 |
88 | ); 89 | }); 90 | -------------------------------------------------------------------------------- /stories/data.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | title: '10 Unbelievable Secrets That Will Make Your Airline Pilot Nervous', 4 | excerpt: 'With these words the Witch fell down in a brown, melted, shapeless mass and began to spread over the clean boards of the kitchen floor. Seeing that she had really melted away to nothing, Dorothy drew another bucket of water and threw it over the mess. She then swept it all out the door. After picking out the silver shoe, which was all that was left of the old woman, she cleaned and dried it with a cloth, and put it on her foot again. Then, being at last free to do as she chose, she ran out to the courtyard to tell the Lion that the Wicked Witch of the West had come to an end, and that they were no longer prisoners in a strange land.', 5 | imageUrl: 'http://lorempixel.com/360/420/cats/1/' 6 | }, 7 | { 8 | title: 'Will Batman Save Leaf Blowing?', 9 | excerpt: 'The splendid fellow sprang to his feet, and grasping me by the shoulder raised his sword on high, exclaiming: "And had the choice been left to me I could not have chosen a more fitting mate for the first princess of Barsoom. Here is my hand upon your shoulder, John Carter, and my word that Sab Than shall go out at the point of my sword for the sake of my love for Helium, for Dejah Thoris, and for you. This very night I shall try to reach his quarters in the palace." "How?" I asked. "You are strongly guarded and a quadruple force patrols the sky." He bent his head in thought a moment, then raised it with an air of confidence.', 10 | imageUrl: 'http://lorempixel.com/360/420/cats/2/' 11 | }, 12 | { 13 | title: '8 Scary Things Your Professor Is Using Against You', 14 | excerpt: 'For a minute he scarcely realised what this meant, and, although the heat was excessive, he clambered down into the pit close to the bulk to see the Thing more clearly. He fancied even then that the cooling of the body might account for this, but what disturbed that idea was the fact that the ash was falling only from the end of the cylinder. And then he perceived that, very slowly, the circular top of the cylinder was rotating on its body. It was such a gradual movement that he discovered it only through noticing that a black mark that had been near him five minutes ago was now at the other side of the circumference.', 15 | imageUrl: 'http://lorempixel.com/360/420/cats/3/' 16 | }, 17 | { 18 | title: 'Kanye West\'s Top 10 Scandalous Microsoft Excel Secrets', 19 | excerpt: 'My wife was curiously silent throughout the drive, and seemed oppressed with forebodings of evil. I talked to her reassuringly, pointing out that the Martians were tied to the Pit by sheer heaviness, and at the utmost could but crawl a little out of it; but she answered only in monosyllables. Had it not been for my promise to the innkeeper, she would, I think, have urged me to stay in Leatherhead that night. Would that I had! Her face, I remember, was very white as we parted. For my own part, I had been feverishly excited all day.', 20 | imageUrl: 'http://lorempixel.com/360/420/cats/4/' 21 | }, 22 | { 23 | title: 'The Embarassing Secrets Of Julia Roberts', 24 | excerpt: 'Passepartout heard the street door shut once; it was his new master going out. He heard it shut again; it was his predecessor, James Forster, departing in his turn. Passepartout remained alone in the house in Saville Row. "Faith," muttered Passepartout, somewhat flurried, "I\'ve seen people at Madame Tussaud\'s as lively as my new master!" Madame Tussaud\'s "people," let it be said, are of wax, and are much visited in London; speech is all that is wanting to make them human. During his brief interview with Mr. Fogg, Passepartout had been carefully observing him.', 25 | imageUrl: 'http://lorempixel.com/360/420/cats/5/' 26 | }, 27 | { 28 | title: '20 Unbelievable Things Girlfriends Won\'t Tell Their Friends', 29 | excerpt: 'On March 3, 1866, Powell and I packed his provisions on two of our burros, and bidding me good-bye he mounted his horse, and started down the mountainside toward the valley, across which led the first stage of his journey. The morning of Powell\'s departure was, like nearly all Arizona mornings, clear and beautiful; I could see him and his little pack animals picking their way down the mountainside toward the valley, and all during the morning I would catch occasional glimpses of them as they topped a hog back or came out upon a level plateau.', 30 | imageUrl: 'http://lorempixel.com/360/420/cats/6/' 31 | }, 32 | { 33 | title: 'Can Vladimir Putin Save Beard Care?', 34 | excerpt: 'So powerfully did the whole grim aspect of Ahab affect me, and the livid brand which streaked it, that for the first few moments I hardly noted that not a little of this overbearing grimness was owing to the barbaric white leg upon which he partly stood. It had previously come to me that this ivory leg had at sea been fashioned from the polished bone of the sperm whale\'s jaw. "Aye, he was dismasted off Japan," said the old Gay-Head Indian once; "but like his dismasted craft, he shipped another mast without coming home for it.', 35 | imageUrl: 'http://lorempixel.com/360/420/cats/7/' 36 | }, 37 | { 38 | title: '15 Truths That Will Make Your Psychiatrist Feel Ashamed', 39 | excerpt: 'Again was I suddenly recalled to my immediate surroundings by a repetition of the weird moan from the depths of the cave. Naked and unarmed as I was, I had no desire to face the unseen thing which menaced me. My revolvers were strapped to my lifeless body which, for some unfathomable reason, I could not bring myself to touch. My carbine was in its boot, strapped to my saddle, and as my horse had wandered off I was left without means of defense. My only alternative seemed to lie in flight and my decision was crystallized by a recurrence of the rustling sound.', 40 | imageUrl: 'http://lorempixel.com/360/420/cats/8/' 41 | }, 42 | { 43 | title: '6 Terrible Facts That Make Boyfriends Stronger', 44 | excerpt: 'First they came to a great hall in which were many ladies and gentlemen of the court, all dressed in rich costumes. These people had nothing to do but talk to each other, but they always came to wait outside the Throne Room every morning, although they were never permitted to see Oz. As Dorothy entered they looked at her curiously, and one of them whispered: "Are you really going to look upon the face of Oz the Terrible?" "Of course," answered the girl, "if he will see me." "Oh, he will see you," said the soldier who had taken her message to the Wizard.', 45 | imageUrl: 'http://lorempixel.com/360/420/cats/9/' 46 | }, 47 | { 48 | title: '5 Surprising Dental Care Tips From Robert De Niro', 49 | excerpt: 'At once, with a quick mental leap, he linked the Thing with the flash upon Mars. The thought of the confined creature was so dreadful to him that he forgot the heat and went forward to the cylinder to help turn. But luckily the dull radiation arrested him before he could burn his hands on the still-glowing metal. At that he stood irresolute for a moment, then turned, scrambled out of the pit, and set off running wildly into Woking. The time then must have been somewhere about six o\'clock. He met a waggoner and tried to make him understand, but the tale he told and his appearance were so wild--his hat had fallen off in the pit--that the man simply drove on.', 50 | imageUrl: 'http://lorempixel.com/360/420/cats/10/' 51 | }, 52 | ]; 53 | -------------------------------------------------------------------------------- /stories/heatmapStory.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import { storiesOf } from "@storybook/react"; 5 | 6 | import range from "lodash.range"; 7 | import { scaleBand, interpolateInferno } from "d3-scale"; 8 | 9 | import ReactCanvas from "../src/index"; 10 | 11 | const { Surface } = ReactCanvas; 12 | 13 | import Alea from "alea"; 14 | 15 | const random = new Alea(0); 16 | random(); 17 | const NUM_ROWS = 16; 18 | const NUM_COLS = 1000; 19 | const rowsRange = range(0, NUM_ROWS); 20 | const colRange = range(0, NUM_COLS); 21 | const rows = rowsRange.map(() => colRange.map(() => random())); 22 | 23 | const heatmapDraw = (ctx, layer) => { 24 | const data = layer.data; 25 | const x = layer.frame.x; 26 | const y = layer.frame.y; 27 | const width = layer.frame.width; 28 | const height = layer.frame.height; 29 | 30 | const fillColor = layer.backgroundColor || "#FFF"; 31 | 32 | const horizontalScale = scaleBand() 33 | .domain(rowsRange) 34 | .range([x, x + width]); 35 | const verticalScale = scaleBand() 36 | .domain(colRange) 37 | .range([y, y + height]); 38 | 39 | ctx.fillStyle = fillColor; 40 | data.forEach((row, rowIdx) => { 41 | row.forEach((col, colIdx) => { 42 | ctx.fillStyle = interpolateInferno(col); 43 | const rectDimensions = { 44 | x: horizontalScale(rowIdx), 45 | y: verticalScale(colIdx), 46 | width: horizontalScale.bandwidth(), 47 | height: verticalScale.bandwidth() 48 | }; 49 | ctx.fillRect( 50 | rectDimensions.x, 51 | rectDimensions.y, 52 | rectDimensions.width, 53 | rectDimensions.height 54 | ); 55 | }); 56 | }); 57 | }; 58 | 59 | const heatmapApplyProps = (layer, style, prevProps, props) => { 60 | layer.shadowColor = style.shadowColor || 0; 61 | layer.shadowOffsetX = style.shadowOffsetX || 0; 62 | layer.shadowOffsetY = style.shadowOffsetY || 0; 63 | layer.shadowBlur = style.shadowBlur || 0; 64 | layer.data = props.data || []; 65 | }; 66 | 67 | const Heatmap = ReactCanvas.registerCustomComponent( 68 | "Heatmap", 69 | heatmapApplyProps, 70 | heatmapDraw 71 | ); 72 | 73 | class App extends React.Component { 74 | static propTypes = { 75 | data: PropTypes.array, 76 | x: PropTypes.number, 77 | y: PropTypes.number, 78 | width: PropTypes.number, 79 | height: PropTypes.number 80 | }; 81 | 82 | render() { 83 | const { data, height, width, x, y } = this.props; 84 | 85 | return ( 86 | 87 | 104 | 105 | ); 106 | } 107 | } 108 | 109 | storiesOf("Heatmap", module).add("heatmap", () => { 110 | const props = { 111 | height: 800, 112 | width: 800, 113 | x: 0, 114 | y: 0, 115 | size: { width: 80, height: 80 } 116 | }; 117 | return ( 118 |
119 | 120 |
121 | ); 122 | }); 123 | -------------------------------------------------------------------------------- /stories/index.js: -------------------------------------------------------------------------------- 1 | import "./canvasStory"; 2 | import "./listviewStory"; 3 | import "./customDrawStory"; 4 | import "./heatmapStory"; 5 | import "./timeline"; 6 | import "./csslayout"; 7 | -------------------------------------------------------------------------------- /stories/listviewStory.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import { storiesOf } from "@storybook/react"; 4 | 5 | import ReactCanvas from "../src/index"; 6 | const { ListView, Surface, Group, Image, Text } = ReactCanvas; 7 | 8 | const articles = [ 9 | { 10 | title: "10 Unbelievable Secrets That Will Make Your Airline Pilot Nervous", 11 | excerpt: 12 | "With these words the Witch fell down in a brown, melted, shapeless mass and began to spread over the clean boards of the kitchen floor. Seeing that she had really melted away to nothing, Dorothy drew another bucket of water and threw it over the mess. She then swept it all out the door. After picking out the silver shoe, which was all that was left of the old woman, she cleaned and dried it with a cloth, and put it on her foot again. Then, being at last free to do as she chose, she ran out to the courtyard to tell the Lion that the Wicked Witch of the West had come to an end, and that they were no longer prisoners in a strange land.", 13 | imageUrl: "http://lorempixel.com/360/420/cats/1/" 14 | }, 15 | { 16 | title: "Will Batman Save Leaf Blowing?", 17 | excerpt: 18 | 'The splendid fellow sprang to his feet, and grasping me by the shoulder raised his sword on high, exclaiming: "And had the choice been left to me I could not have chosen a more fitting mate for the first princess of Barsoom. Here is my hand upon your shoulder, John Carter, and my word that Sab Than shall go out at the point of my sword for the sake of my love for Helium, for Dejah Thoris, and for you. This very night I shall try to reach his quarters in the palace." "How?" I asked. "You are strongly guarded and a quadruple force patrols the sky." He bent his head in thought a moment, then raised it with an air of confidence.', 19 | imageUrl: "http://lorempixel.com/360/420/cats/2/" 20 | }, 21 | { 22 | title: "8 Scary Things Your Professor Is Using Against You", 23 | excerpt: 24 | "For a minute he scarcely realised what this meant, and, although the heat was excessive, he clambered down into the pit close to the bulk to see the Thing more clearly. He fancied even then that the cooling of the body might account for this, but what disturbed that idea was the fact that the ash was falling only from the end of the cylinder. And then he perceived that, very slowly, the circular top of the cylinder was rotating on its body. It was such a gradual movement that he discovered it only through noticing that a black mark that had been near him five minutes ago was now at the other side of the circumference.", 25 | imageUrl: "http://lorempixel.com/360/420/cats/3/" 26 | }, 27 | { 28 | title: "Kanye West's Top 10 Scandalous Microsoft Excel Secrets", 29 | excerpt: 30 | "My wife was curiously silent throughout the drive, and seemed oppressed with forebodings of evil. I talked to her reassuringly, pointing out that the Martians were tied to the Pit by sheer heaviness, and at the utmost could but crawl a little out of it; but she answered only in monosyllables. Had it not been for my promise to the innkeeper, she would, I think, have urged me to stay in Leatherhead that night. Would that I had! Her face, I remember, was very white as we parted. For my own part, I had been feverishly excited all day.", 31 | imageUrl: "http://lorempixel.com/360/420/cats/4/" 32 | }, 33 | { 34 | title: "The Embarassing Secrets Of Julia Roberts", 35 | excerpt: 36 | 'Passepartout heard the street door shut once; it was his new master going out. He heard it shut again; it was his predecessor, James Forster, departing in his turn. Passepartout remained alone in the house in Saville Row. "Faith," muttered Passepartout, somewhat flurried, "I\'ve seen people at Madame Tussaud\'s as lively as my new master!" Madame Tussaud\'s "people," let it be said, are of wax, and are much visited in London; speech is all that is wanting to make them human. During his brief interview with Mr. Fogg, Passepartout had been carefully observing him.', 37 | imageUrl: "http://lorempixel.com/360/420/cats/5/" 38 | }, 39 | { 40 | title: "20 Unbelievable Things Girlfriends Won't Tell Their Friends", 41 | excerpt: 42 | "On March 3, 1866, Powell and I packed his provisions on two of our burros, and bidding me good-bye he mounted his horse, and started down the mountainside toward the valley, across which led the first stage of his journey. The morning of Powell's departure was, like nearly all Arizona mornings, clear and beautiful; I could see him and his little pack animals picking their way down the mountainside toward the valley, and all during the morning I would catch occasional glimpses of them as they topped a hog back or came out upon a level plateau.", 43 | imageUrl: "http://lorempixel.com/360/420/cats/6/" 44 | }, 45 | { 46 | title: "Can Vladimir Putin Save Beard Care?", 47 | excerpt: 48 | 'So powerfully did the whole grim aspect of Ahab affect me, and the livid brand which streaked it, that for the first few moments I hardly noted that not a little of this overbearing grimness was owing to the barbaric white leg upon which he partly stood. It had previously come to me that this ivory leg had at sea been fashioned from the polished bone of the sperm whale\'s jaw. "Aye, he was dismasted off Japan," said the old Gay-Head Indian once; "but like his dismasted craft, he shipped another mast without coming home for it.', 49 | imageUrl: "http://lorempixel.com/360/420/cats/7/" 50 | }, 51 | { 52 | title: "15 Truths That Will Make Your Psychiatrist Feel Ashamed", 53 | excerpt: 54 | "Again was I suddenly recalled to my immediate surroundings by a repetition of the weird moan from the depths of the cave. Naked and unarmed as I was, I had no desire to face the unseen thing which menaced me. My revolvers were strapped to my lifeless body which, for some unfathomable reason, I could not bring myself to touch. My carbine was in its boot, strapped to my saddle, and as my horse had wandered off I was left without means of defense. My only alternative seemed to lie in flight and my decision was crystallized by a recurrence of the rustling sound.", 55 | imageUrl: "http://lorempixel.com/360/420/cats/8/" 56 | }, 57 | { 58 | title: "6 Terrible Facts That Make Boyfriends Stronger", 59 | excerpt: 60 | 'First they came to a great hall in which were many ladies and gentlemen of the court, all dressed in rich costumes. These people had nothing to do but talk to each other, but they always came to wait outside the Throne Room every morning, although they were never permitted to see Oz. As Dorothy entered they looked at her curiously, and one of them whispered: "Are you really going to look upon the face of Oz the Terrible?" "Of course," answered the girl, "if he will see me." "Oh, he will see you," said the soldier who had taken her message to the Wizard.', 61 | imageUrl: "http://lorempixel.com/360/420/cats/9/" 62 | }, 63 | { 64 | title: "5 Surprising Dental Care Tips From Robert De Niro", 65 | excerpt: 66 | "At once, with a quick mental leap, he linked the Thing with the flash upon Mars. The thought of the confined creature was so dreadful to him that he forgot the heat and went forward to the cylinder to help turn. But luckily the dull radiation arrested him before he could burn his hands on the still-glowing metal. At that he stood irresolute for a moment, then turned, scrambled out of the pit, and set off running wildly into Woking. The time then must have been somewhere about six o'clock. He met a waggoner and tried to make him understand, but the tale he told and his appearance were so wild--his hat had fallen off in the pit--that the man simply drove on.", 67 | imageUrl: "http://lorempixel.com/360/420/cats/10/" 68 | } 69 | ]; 70 | 71 | class Item extends React.Component { 72 | static propTypes = { 73 | width: PropTypes.number, 74 | height: PropTypes.number, 75 | imageUrl: PropTypes.string, 76 | title: PropTypes.string, 77 | itemIndex: PropTypes.number 78 | }; 79 | 80 | static getItemHeight = () => { 81 | return 80; 82 | }; 83 | 84 | render() { 85 | return ( 86 | console.log("Clicked " + this.props.title)} 89 | > 90 | 91 | {this.props.title} 92 | 93 | ); 94 | } 95 | 96 | getStyle = () => { 97 | return { 98 | width: this.props.width, 99 | height: Item.getItemHeight(), 100 | backgroundColor: this.props.itemIndex % 2 ? "#eee" : "#a5d2ee" 101 | }; 102 | }; 103 | 104 | getImageStyle = () => { 105 | return { 106 | top: 10, 107 | left: 10, 108 | width: 60, 109 | height: 60, 110 | backgroundColor: "#ddd", 111 | borderColor: "#999", 112 | borderWidth: 1 113 | }; 114 | }; 115 | 116 | getTitleStyle = () => { 117 | return { 118 | top: 32, 119 | left: 80, 120 | width: this.props.width - 90, 121 | height: 18, 122 | fontSize: 14, 123 | lineHeight: 18 124 | }; 125 | }; 126 | } 127 | 128 | class App extends React.Component { 129 | render() { 130 | const size = this.getSize(); 131 | return ( 132 | 133 | 139 | 140 | ); 141 | } 142 | 143 | renderItem = itemIndex => { 144 | const article = articles[itemIndex % articles.length]; 145 | return ( 146 | 153 | ); 154 | }; 155 | 156 | getSize = () => { 157 | return { 158 | width: 800, 159 | height: 400 160 | }; 161 | }; 162 | 163 | // ListView 164 | // ======== 165 | 166 | getListViewStyle = () => { 167 | return { 168 | top: 0, 169 | left: 0, 170 | width: this.getSize().width, 171 | height: this.getSize().height 172 | }; 173 | }; 174 | 175 | getNumberOfItems = () => { 176 | return 1000; 177 | }; 178 | } 179 | 180 | storiesOf("ListView", module).add("transparent-grey", () => { 181 | return ( 182 |
183 | 184 |
185 | ); 186 | }); 187 | -------------------------------------------------------------------------------- /stories/timeline.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactCanvas from "../src/index"; 3 | import Page from "./components/Page"; 4 | import articles from "./data"; 5 | 6 | import { storiesOf } from "@storybook/react"; 7 | const { ListView, Surface } = ReactCanvas; 8 | 9 | class App extends React.Component { 10 | render() { 11 | const size = this.getSize(); 12 | 13 | return ( 14 | 15 | 24 | 25 | ); 26 | } 27 | 28 | renderPage = (pageIndex, scrollTop) => { 29 | const size = this.getSize(); 30 | const article = articles[pageIndex % articles.length]; 31 | const pageScrollTop = pageIndex * this.getPageHeight() - scrollTop; 32 | 33 | return ( 34 | 41 | ); 42 | }; 43 | 44 | getSize = () => { 45 | const size = document.getElementById("root").getBoundingClientRect(); 46 | size.height = 800; 47 | return size; 48 | }; 49 | 50 | getListViewStyle = () => { 51 | const size = this.getSize(); 52 | 53 | return { 54 | top: 0, 55 | left: 0, 56 | width: size.width, 57 | height: size.height, 58 | backgroundColor: "#320000" 59 | }; 60 | }; 61 | 62 | getNumberOfPages = () => { 63 | return 1000; 64 | }; 65 | 66 | getPageHeight = () => { 67 | return this.getSize().height; 68 | }; 69 | } 70 | 71 | storiesOf("Timeline", module).add("app", () => { 72 | return ; 73 | }); 74 | -------------------------------------------------------------------------------- /webpack.config.babel.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | const config = { 4 | entry: "./src/index.js", 5 | output: { 6 | filename: "bundle.js", 7 | path: path.join(__dirname, "/dist"), 8 | 9 | // the name of the exported library 10 | 11 | libraryTarget: "commonjs" // universal module definition 12 | // the type of the exported library 13 | }, 14 | 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.js$/, 19 | exclude: /(node_modules)/, 20 | use: { 21 | loader: "babel-loader", 22 | query: { 23 | plugins: [ 24 | "transform-class-properties", 25 | "transform-object-rest-spread", 26 | "transform-decorators-legacy" 27 | ] 28 | } 29 | } 30 | } 31 | ] 32 | } 33 | }; 34 | 35 | export default config; 36 | --------------------------------------------------------------------------------