├── .babelrc ├── .gitignore ├── LICENSE ├── README.md ├── examples ├── common │ ├── data.js │ ├── examples.css │ └── touch-emulator.js ├── css-layout │ ├── app.js │ └── index.html ├── gradient │ ├── app.js │ └── index.html ├── listview │ ├── app.js │ ├── components │ │ └── Item.js │ └── index.html └── timeline │ ├── app.js │ ├── components │ └── Page.js │ └── index.html ├── gulpfile.js ├── lib ├── Canvas.js ├── CanvasUtils.js ├── ContainerMixin.js ├── DrawingUtils.js ├── Easing.js ├── EventTypes.js ├── FontFace.js ├── FontUtils.js ├── FrameUtils.js ├── Gradient.js ├── Group.js ├── Image.js ├── ImageCache.js ├── Layer.js ├── LayerMixin.js ├── Layout.js ├── ListView.js ├── ReactCanvas.js ├── RenderLayer.js ├── Surface.js ├── Text.js ├── __tests__ │ └── clamp-test.js ├── clamp.js ├── createComponent.js ├── hitTest.js ├── layoutNode.js └── measureText.js ├── package.json └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react"] 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules 3 | yarn.lock 4 | npm-debug.log -------------------------------------------------------------------------------- /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 | [Introductory blog post](http://engineering.flipboard.com/2015/02/mobile-web) 4 | 5 | React Canvas adds the ability for React components to render to `` rather than DOM. 6 | 7 | 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. 8 | 9 | ## Motivation 10 | 11 | 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. 12 | 13 | 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. 14 | 15 | React Canvas brings some of the APIs web developers are familiar with and blends them with a high performance drawing engine. 16 | 17 | ## Installation 18 | 19 | React Canvas is available through npm: 20 | 21 | ```npm install react-canvas``` 22 | 23 | ## React Canvas Components 24 | 25 | React Canvas provides a set of standard React components that abstract the underlying rendering implementation. 26 | 27 | ### <Surface> 28 | 29 | **Surface** is the top-level component. Think of it as a drawing canvas in which you can place other components. 30 | 31 | ### <Layer> 32 | 33 | **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. 34 | 35 | ### <Group> 36 | 37 | **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. 38 | 39 | ### <Text> 40 | 41 | **Text** is a flexible component that supports multi-line truncation, something which has historically been difficult and very expensive to do in DOM. 42 | 43 | ### <Image> 44 | 45 | **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. 46 | 47 | ### <Gradient> 48 | 49 | **Gradient** can be used to set the background of a group or surface. 50 | ```javascript 51 | render() { 52 | ... 53 | return ( 54 | 55 | 57 | 58 | ); 59 | } 60 | getGradientColors(){ 61 | return [ 62 | { color: "transparent", position: 0 }, 63 | { color: "#000", position: 1 } 64 | ] 65 | } 66 | ``` 67 | 68 | ### <ListView> 69 | 70 | **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. 71 | 72 | ## Events 73 | 74 | React Canvas components support the same event model as normal React components. However, not all event types are currently supported. 75 | 76 | For a full list of supported events see [EventTypes](lib/EventTypes.js). 77 | 78 | ## Building Components 79 | 80 | Here is a very simple component that renders text below an image: 81 | 82 | ```javascript 83 | var React = require('react'); 84 | var ReactCanvas = require('react-canvas'); 85 | 86 | var Surface = ReactCanvas.Surface; 87 | var Image = ReactCanvas.Image; 88 | var Text = ReactCanvas.Text; 89 | 90 | var MyComponent = React.createClass({ 91 | 92 | render: function () { 93 | var surfaceWidth = window.innerWidth; 94 | var surfaceHeight = window.innerHeight; 95 | var imageStyle = this.getImageStyle(); 96 | var textStyle = this.getTextStyle(); 97 | 98 | return ( 99 | 100 | 101 | 102 | Here is some text below an image. 103 | 104 | 105 | ); 106 | }, 107 | 108 | getImageHeight: function () { 109 | return Math.round(window.innerHeight / 2); 110 | }, 111 | 112 | getImageStyle: function () { 113 | return { 114 | top: 0, 115 | left: 0, 116 | width: window.innerWidth, 117 | height: this.getImageHeight() 118 | }; 119 | }, 120 | 121 | getTextStyle: function () { 122 | return { 123 | top: this.getImageHeight() + 10, 124 | left: 0, 125 | width: window.innerWidth, 126 | height: 20, 127 | lineHeight: 20, 128 | fontSize: 12 129 | }; 130 | } 131 | 132 | }); 133 | ``` 134 | 135 | ## ListView 136 | 137 | Many mobile interfaces involve an infinitely long scrolling list of items. React Canvas provides the ListView component to do just that. 138 | 139 | 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(). 140 | 141 | The `numberOfItemsGetter`, `itemHeightGetter` and `itemGetter` props are all required. 142 | 143 | ```javascript 144 | var ListView = ReactCanvas.ListView; 145 | 146 | var MyScrollingListView = React.createClass({ 147 | 148 | render: function () { 149 | return ( 150 | 154 | ); 155 | }, 156 | 157 | getNumberOfItems: function () { 158 | // Return the total number of items in the list 159 | }, 160 | 161 | getItemHeight: function () { 162 | // Return the height of a single item 163 | }, 164 | 165 | renderItem: function (index) { 166 | // Render the item at the given index, usually a 167 | }, 168 | 169 | }); 170 | ``` 171 | 172 | See the [timeline example](examples/timeline/app.js) for a more complete example. 173 | 174 | Currently, ListView requires that each item is of the same height. Future versions will support variable height items. 175 | 176 | ## Text sizing 177 | 178 | React Canvas provides the `measureText` function for computing text metrics. 179 | 180 | 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. 181 | 182 | Custom fonts are not currently supported but will be added in a future version. 183 | 184 | ## css-layout 185 | 186 | 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. 187 | 188 | 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. 189 | 190 | See the [css-layout example](examples/css-layout). 191 | 192 | ## Accessibility 193 | 194 | 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. 195 | 196 | 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. 197 | 198 | ## Running the examples 199 | 200 | ``` 201 | npm install 202 | npm start 203 | ``` 204 | 205 | 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. 206 | 207 | **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. 208 | 209 | 210 | ## Using with webpack 211 | 212 | The [brfs](https://github.com/substack/brfs) transform is required in order to use the project with webpack. 213 | 214 | ```bash 215 | npm install -g brfs 216 | npm install --save-dev transform-loader brfs 217 | ``` 218 | 219 | Then add the [brfs](https://github.com/substack/brfs) transform to your webpack config 220 | 221 | ```javascript 222 | module: { 223 | postLoaders: [ 224 | { loader: "transform?brfs" } 225 | ] 226 | } 227 | ``` 228 | 229 | ## Contributing 230 | 231 | 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. 232 | -------------------------------------------------------------------------------- /examples/common/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: 'https://placekitten.com/360/420' 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: 'https://placekitten.com/361/421' 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: 'https://placekitten.com/362/422' 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: 'https://placekitten.com/363/423' 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: 'https://placekitten.com/364/424' 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: 'https://placekitten.com/365/425' 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: 'https://placekitten.com/366/426' 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: 'https://placekitten.com/367/427' 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: 'https://placekitten.com/368/428' 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: 'https://placekitten.com/369/429' 51 | }, 52 | ]; 53 | -------------------------------------------------------------------------------- /examples/common/examples.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 0; 3 | padding: 0; 4 | font: 16px Helvetica, sans-serif; 5 | height: 100%; 6 | overflow: hidden; 7 | background: #ddd; 8 | } 9 | 10 | #main { 11 | background: #fff; 12 | position: relative; 13 | height: 100%; 14 | max-width: 420px; 15 | max-height: 700px; 16 | } 17 | -------------------------------------------------------------------------------- /examples/common/touch-emulator.js: -------------------------------------------------------------------------------- 1 | // https://github.com/hammerjs/touchemulator 2 | 3 | (function(window, document, exportName, undefined) { 4 | "use strict"; 5 | 6 | var isMultiTouch = false; 7 | var multiTouchStartPos; 8 | var eventTarget; 9 | var touchElements = {}; 10 | 11 | // polyfills 12 | if(!document.createTouch) { 13 | document.createTouch = function(view, target, identifier, pageX, pageY, screenX, screenY, clientX, clientY) { 14 | // auto set 15 | if(clientX == undefined || clientY == undefined) { 16 | clientX = pageX - window.pageXOffset; 17 | clientY = pageY - window.pageYOffset; 18 | } 19 | 20 | return new Touch(target, identifier, { 21 | pageX: pageX, 22 | pageY: pageY, 23 | screenX: screenX, 24 | screenY: screenY, 25 | clientX: clientX, 26 | clientY: clientY 27 | }); 28 | }; 29 | } 30 | 31 | if(!document.createTouchList) { 32 | document.createTouchList = function() { 33 | var touchList = new TouchList(); 34 | for (var i = 0; i < arguments.length; i++) { 35 | touchList[i] = arguments[i]; 36 | } 37 | touchList.length = arguments.length; 38 | return touchList; 39 | }; 40 | } 41 | 42 | /** 43 | * create an touch point 44 | * @constructor 45 | * @param target 46 | * @param identifier 47 | * @param pos 48 | * @param deltaX 49 | * @param deltaY 50 | * @returns {Object} touchPoint 51 | */ 52 | function Touch(target, identifier, pos, deltaX, deltaY) { 53 | deltaX = deltaX || 0; 54 | deltaY = deltaY || 0; 55 | 56 | this.identifier = identifier; 57 | this.target = target; 58 | this.clientX = pos.clientX + deltaX; 59 | this.clientY = pos.clientY + deltaY; 60 | this.screenX = pos.screenX + deltaX; 61 | this.screenY = pos.screenY + deltaY; 62 | this.pageX = pos.pageX + deltaX; 63 | this.pageY = pos.pageY + deltaY; 64 | } 65 | 66 | /** 67 | * create empty touchlist with the methods 68 | * @constructor 69 | * @returns touchList 70 | */ 71 | function TouchList() { 72 | var touchList = []; 73 | 74 | touchList.item = function(index) { 75 | return this[index] || null; 76 | }; 77 | 78 | // specified by Mozilla 79 | touchList.identifiedTouch = function(id) { 80 | return this[id + 1] || null; 81 | }; 82 | 83 | return touchList; 84 | } 85 | 86 | 87 | /** 88 | * Simple trick to fake touch event support 89 | * this is enough for most libraries like Modernizr and Hammer 90 | */ 91 | function fakeTouchSupport() { 92 | var objs = [window, document.documentElement]; 93 | var props = ['ontouchstart', 'ontouchmove', 'ontouchcancel', 'ontouchend']; 94 | 95 | for(var o=0; o 2; // pointer events 112 | } 113 | 114 | /** 115 | * disable mouseevents on the page 116 | * @param ev 117 | */ 118 | function preventMouseEvents(ev) { 119 | ev.preventDefault(); 120 | ev.stopPropagation(); 121 | } 122 | 123 | /** 124 | * only trigger touches when the left mousebutton has been pressed 125 | * @param touchType 126 | * @returns {Function} 127 | */ 128 | function onMouse(touchType) { 129 | return function(ev) { 130 | // prevent mouse events 131 | preventMouseEvents(ev); 132 | 133 | if (ev.which !== 1) { 134 | return; 135 | } 136 | 137 | // The EventTarget on which the touch point started when it was first placed on the surface, 138 | // even if the touch point has since moved outside the interactive area of that element. 139 | // also, when the target doesnt exist anymore, we update it 140 | if (ev.type == 'mousedown' || !eventTarget || (eventTarget && !eventTarget.dispatchEvent)) { 141 | eventTarget = ev.target; 142 | } 143 | 144 | // shiftKey has been lost, so trigger a touchend 145 | if (isMultiTouch && !ev.shiftKey) { 146 | triggerTouch('touchend', ev); 147 | isMultiTouch = false; 148 | } 149 | 150 | triggerTouch(touchType, ev); 151 | 152 | // we're entering the multi-touch mode! 153 | if (!isMultiTouch && ev.shiftKey) { 154 | isMultiTouch = true; 155 | multiTouchStartPos = { 156 | pageX: ev.pageX, 157 | pageY: ev.pageY, 158 | clientX: ev.clientX, 159 | clientY: ev.clientY, 160 | screenX: ev.screenX, 161 | screenY: ev.screenY 162 | }; 163 | triggerTouch('touchstart', ev); 164 | } 165 | 166 | // reset 167 | if (ev.type == 'mouseup') { 168 | multiTouchStartPos = null; 169 | isMultiTouch = false; 170 | eventTarget = null; 171 | } 172 | } 173 | } 174 | 175 | /** 176 | * trigger a touch event 177 | * @param eventName 178 | * @param mouseEv 179 | */ 180 | function triggerTouch(eventName, mouseEv) { 181 | var touchEvent = document.createEvent('Event'); 182 | touchEvent.initEvent(eventName, true, true); 183 | 184 | touchEvent.altKey = mouseEv.altKey; 185 | touchEvent.ctrlKey = mouseEv.ctrlKey; 186 | touchEvent.metaKey = mouseEv.metaKey; 187 | touchEvent.shiftKey = mouseEv.shiftKey; 188 | 189 | touchEvent.touches = getActiveTouches(mouseEv, eventName); 190 | touchEvent.targetTouches = getActiveTouches(mouseEv, eventName); 191 | touchEvent.changedTouches = getChangedTouches(mouseEv, eventName); 192 | 193 | eventTarget.dispatchEvent(touchEvent); 194 | } 195 | 196 | /** 197 | * create a touchList based on the mouse event 198 | * @param mouseEv 199 | * @returns {TouchList} 200 | */ 201 | function createTouchList(mouseEv) { 202 | var touchList = new TouchList(); 203 | 204 | if (isMultiTouch) { 205 | var f = TouchEmulator.multiTouchOffset; 206 | var deltaX = multiTouchStartPos.pageX - mouseEv.pageX; 207 | var deltaY = multiTouchStartPos.pageY - mouseEv.pageY; 208 | 209 | touchList.push(new Touch(eventTarget, 1, multiTouchStartPos, (deltaX*-1) - f, (deltaY*-1) + f)); 210 | touchList.push(new Touch(eventTarget, 2, multiTouchStartPos, deltaX+f, deltaY-f)); 211 | } else { 212 | touchList.push(new Touch(eventTarget, 1, mouseEv, 0, 0)); 213 | } 214 | 215 | return touchList; 216 | } 217 | 218 | /** 219 | * receive all active touches 220 | * @param mouseEv 221 | * @returns {TouchList} 222 | */ 223 | function getActiveTouches(mouseEv, eventName) { 224 | // empty list 225 | if (mouseEv.type == 'mouseup') { 226 | return new TouchList(); 227 | } 228 | 229 | var touchList = createTouchList(mouseEv); 230 | if(isMultiTouch && mouseEv.type != 'mouseup' && eventName == 'touchend') { 231 | touchList.splice(1, 1); 232 | } 233 | return touchList; 234 | } 235 | 236 | /** 237 | * receive a filtered set of touches with only the changed pointers 238 | * @param mouseEv 239 | * @param eventName 240 | * @returns {TouchList} 241 | */ 242 | function getChangedTouches(mouseEv, eventName) { 243 | var touchList = createTouchList(mouseEv); 244 | 245 | // we only want to return the added/removed item on multitouch 246 | // which is the second pointer, so remove the first pointer from the touchList 247 | // 248 | // but when the mouseEv.type is mouseup, we want to send all touches because then 249 | // no new input will be possible 250 | if(isMultiTouch && mouseEv.type != 'mouseup' && 251 | (eventName == 'touchstart' || eventName == 'touchend')) { 252 | touchList.splice(0, 1); 253 | } 254 | 255 | return touchList; 256 | } 257 | 258 | /** 259 | * show the touchpoints on the screen 260 | */ 261 | function showTouches(ev) { 262 | var touch, i, el, styles; 263 | 264 | // first all visible touches 265 | for(i = 0; i < ev.touches.length; i++) { 266 | touch = ev.touches[i]; 267 | el = touchElements[touch.identifier]; 268 | if(!el) { 269 | el = touchElements[touch.identifier] = document.createElement("div"); 270 | document.body.appendChild(el); 271 | } 272 | 273 | styles = TouchEmulator.template(touch); 274 | for(var prop in styles) { 275 | el.style[prop] = styles[prop]; 276 | } 277 | } 278 | 279 | // remove all ended touches 280 | if(ev.type == 'touchend' || ev.type == 'touchcancel') { 281 | for(i = 0; i < ev.changedTouches.length; i++) { 282 | touch = ev.changedTouches[i]; 283 | el = touchElements[touch.identifier]; 284 | if(el) { 285 | el.parentNode.removeChild(el); 286 | delete touchElements[touch.identifier]; 287 | } 288 | } 289 | } 290 | } 291 | 292 | /** 293 | * TouchEmulator initializer 294 | */ 295 | function TouchEmulator() { 296 | if (hasTouchSupport()) { 297 | return; 298 | } 299 | 300 | fakeTouchSupport(); 301 | 302 | window.addEventListener("mousedown", onMouse('touchstart'), true); 303 | window.addEventListener("mousemove", onMouse('touchmove'), true); 304 | window.addEventListener("mouseup", onMouse('touchend'), true); 305 | 306 | window.addEventListener("mouseenter", preventMouseEvents, true); 307 | window.addEventListener("mouseleave", preventMouseEvents, true); 308 | window.addEventListener("mouseout", preventMouseEvents, true); 309 | window.addEventListener("mouseover", preventMouseEvents, true); 310 | 311 | // it uses itself! 312 | window.addEventListener("touchstart", showTouches, false); 313 | window.addEventListener("touchmove", showTouches, false); 314 | window.addEventListener("touchend", showTouches, false); 315 | window.addEventListener("touchcancel", showTouches, false); 316 | } 317 | 318 | // start distance when entering the multitouch mode 319 | TouchEmulator.multiTouchOffset = 75; 320 | 321 | /** 322 | * css template for the touch rendering 323 | * @param touch 324 | * @returns object 325 | */ 326 | TouchEmulator.template = function(touch) { 327 | var size = 30; 328 | var transform = 'translate('+ (touch.clientX-(size/2)) +'px, '+ (touch.clientY-(size/2)) +'px)'; 329 | return { 330 | position: 'fixed', 331 | left: 0, 332 | top: 0, 333 | background: '#fff', 334 | border: 'solid 1px #999', 335 | opacity: .6, 336 | borderRadius: '100%', 337 | height: size + 'px', 338 | width: size + 'px', 339 | padding: 0, 340 | margin: 0, 341 | display: 'block', 342 | overflow: 'hidden', 343 | pointerEvents: 'none', 344 | webkitUserSelect: 'none', 345 | mozUserSelect: 'none', 346 | userSelect: 'none', 347 | webkitTransform: transform, 348 | mozTransform: transform, 349 | transform: transform 350 | } 351 | }; 352 | 353 | // export 354 | if (typeof define == "function" && define.amd) { 355 | define(function() { 356 | return TouchEmulator; 357 | }); 358 | } else if (typeof module != "undefined" && module.exports) { 359 | module.exports = TouchEmulator; 360 | } else { 361 | window[exportName] = TouchEmulator; 362 | } 363 | })(window, document, "TouchEmulator"); -------------------------------------------------------------------------------- /examples/css-layout/app.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var ReactDOM = require('react-dom'); 3 | var ReactCanvas = require('react-canvas'); 4 | 5 | var Surface = ReactCanvas.Surface; 6 | var Group = ReactCanvas.Group; 7 | var Image = ReactCanvas.Image; 8 | var Text = ReactCanvas.Text; 9 | var FontFace = ReactCanvas.FontFace; 10 | 11 | var App = React.createClass({ 12 | 13 | componentDidMount: function () { 14 | window.addEventListener('resize', this.handleResize, true); 15 | }, 16 | 17 | render: function () { 18 | var size = this.getSize(); 19 | return ( 20 | 21 | 22 | 23 | Professor PuddinPop 24 | 25 | 26 | 27 | 28 | 29 | 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. 30 | 31 | 32 | 33 | ); 34 | }, 35 | 36 | // Styles 37 | // ====== 38 | 39 | getSize: function () { 40 | return document.getElementById('main').getBoundingClientRect(); 41 | }, 42 | 43 | getPageStyle: function () { 44 | var size = this.getSize(); 45 | return { 46 | position: 'relative', 47 | padding: 14, 48 | width: size.width, 49 | height: size.height, 50 | backgroundColor: '#f7f7f7', 51 | flexDirection: 'column' 52 | }; 53 | }, 54 | 55 | getImageGroupStyle: function () { 56 | return { 57 | position: 'relative', 58 | flex: 1, 59 | backgroundColor: '#eee' 60 | }; 61 | }, 62 | 63 | getImageStyle: function () { 64 | return { 65 | position: 'absolute', 66 | left: 0, 67 | top: 0, 68 | right: 0, 69 | bottom: 0 70 | }; 71 | }, 72 | 73 | getTitleStyle: function () { 74 | return { 75 | fontFace: FontFace('Georgia'), 76 | fontSize: 22, 77 | lineHeight: 28, 78 | height: 28, 79 | marginBottom: 10, 80 | color: '#333', 81 | textAlign: 'center' 82 | }; 83 | }, 84 | 85 | getExcerptStyle: function () { 86 | return { 87 | fontFace: FontFace('Georgia'), 88 | fontSize: 17, 89 | lineHeight: 25, 90 | marginTop: 15, 91 | flex: 1, 92 | color: '#333' 93 | }; 94 | }, 95 | 96 | // Events 97 | // ====== 98 | 99 | handleResize: function () { 100 | this.forceUpdate(); 101 | } 102 | 103 | }); 104 | 105 | ReactDOM.render(, document.getElementById('main')); 106 | -------------------------------------------------------------------------------- /examples/css-layout/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ReactCanvas: css-layout 7 | 8 | 9 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /examples/gradient/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react'); 4 | var ReactDOM = require('react-dom'); 5 | var ReactCanvas = require('react-canvas'); 6 | 7 | var Gradient = ReactCanvas.Gradient; 8 | var Surface = ReactCanvas.Surface; 9 | 10 | var App = React.createClass({ 11 | 12 | render: function () { 13 | var size = this.getSize(); 14 | return ( 15 | 16 | 18 | 19 | ); 20 | }, 21 | 22 | getGradientStyle: function(){ 23 | var size = this.getSize(); 24 | return { 25 | top: 0, 26 | left: 0, 27 | width: size.width, 28 | height: size.height 29 | }; 30 | }, 31 | 32 | getGradientColors: function(){ 33 | return [ 34 | { color: "transparent", position: 0 }, 35 | { color: "#000", position: 1 } 36 | ]; 37 | }, 38 | 39 | getSize: function () { 40 | return document.getElementById('main').getBoundingClientRect(); 41 | } 42 | 43 | }); 44 | 45 | ReactDOM.render(, document.getElementById('main')); 46 | -------------------------------------------------------------------------------- /examples/gradient/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ReactCanvas: ListView 7 | 8 | 9 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /examples/listview/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react'); 4 | var ReactDOM = require('react-dom'); 5 | var ReactCanvas = require('react-canvas'); 6 | var Item = require('./components/Item'); 7 | var articles = require('../common/data'); 8 | 9 | var Surface = ReactCanvas.Surface; 10 | var ListView = ReactCanvas.ListView; 11 | 12 | var App = React.createClass({ 13 | 14 | render: function () { 15 | var size = this.getSize(); 16 | return ( 17 | 18 | 23 | 24 | ); 25 | }, 26 | 27 | renderItem: function (itemIndex, scrollTop) { 28 | var article = articles[itemIndex % articles.length]; 29 | return ( 30 | 36 | ); 37 | }, 38 | 39 | getSize: function () { 40 | return document.getElementById('main').getBoundingClientRect(); 41 | }, 42 | 43 | // ListView 44 | // ======== 45 | 46 | getListViewStyle: function () { 47 | return { 48 | top: 0, 49 | left: 0, 50 | width: window.innerWidth, 51 | height: window.innerHeight 52 | }; 53 | }, 54 | 55 | getNumberOfItems: function () { 56 | return 1000; 57 | }, 58 | 59 | }); 60 | 61 | ReactDOM.render(, document.getElementById('main')); 62 | -------------------------------------------------------------------------------- /examples/listview/components/Item.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react'); 4 | var ReactCanvas = require('react-canvas'); 5 | 6 | var Group = ReactCanvas.Group; 7 | var Image = ReactCanvas.Image; 8 | var Text = ReactCanvas.Text; 9 | 10 | var Item = React.createClass({ 11 | 12 | propTypes: { 13 | width: React.PropTypes.number.isRequired, 14 | height: React.PropTypes.number.isRequired, 15 | imageUrl: React.PropTypes.string.isRequired, 16 | title: React.PropTypes.string.isRequired, 17 | itemIndex: React.PropTypes.number.isRequired, 18 | }, 19 | 20 | statics: { 21 | getItemHeight: function () { 22 | return 80; 23 | } 24 | }, 25 | 26 | render: function () { 27 | return ( 28 | 29 | 30 | {this.props.title} 31 | 32 | ); 33 | }, 34 | 35 | getStyle: function () { 36 | return { 37 | width: this.props.width, 38 | height: Item.getItemHeight(), 39 | backgroundColor: (this.props.itemIndex % 2) ? '#eee' : '#a5d2ee' 40 | }; 41 | }, 42 | 43 | getImageStyle: function () { 44 | return { 45 | top: 10, 46 | left: 10, 47 | width: 60, 48 | height: 60, 49 | backgroundColor: '#ddd', 50 | borderColor: '#999', 51 | borderWidth: 1 52 | }; 53 | }, 54 | 55 | getTitleStyle: function () { 56 | return { 57 | top: 32, 58 | left: 80, 59 | width: this.props.width - 90, 60 | height: 18, 61 | fontSize: 14, 62 | lineHeight: 18 63 | }; 64 | } 65 | 66 | }); 67 | 68 | module.exports = Item; 69 | -------------------------------------------------------------------------------- /examples/listview/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ReactCanvas: ListView 7 | 8 | 9 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /examples/timeline/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react'); 4 | var ReactDOM = require('react-dom'); 5 | var ReactCanvas = require('react-canvas'); 6 | var Page = require('./components/Page'); 7 | var articles = require('../common/data'); 8 | 9 | var Surface = ReactCanvas.Surface; 10 | var ListView = ReactCanvas.ListView; 11 | 12 | var App = React.createClass({ 13 | 14 | render: function () { 15 | var size = this.getSize(); 16 | return ( 17 | 18 | 26 | 27 | ); 28 | }, 29 | 30 | renderPage: function (pageIndex, scrollTop) { 31 | var size = this.getSize(); 32 | var article = articles[pageIndex % articles.length]; 33 | var pageScrollTop = pageIndex * this.getPageHeight() - scrollTop; 34 | return ( 35 | 41 | ); 42 | }, 43 | 44 | getSize: function () { 45 | return document.getElementById('main').getBoundingClientRect(); 46 | }, 47 | 48 | // ListView 49 | // ======== 50 | 51 | getListViewStyle: function () { 52 | var size = this.getSize(); 53 | return { 54 | top: 0, 55 | left: 0, 56 | width: size.width, 57 | height: size.height 58 | }; 59 | }, 60 | 61 | getNumberOfPages: function () { 62 | return 1000; 63 | }, 64 | 65 | getPageHeight: function () { 66 | return this.getSize().height; 67 | } 68 | 69 | }); 70 | 71 | ReactDOM.render(, document.getElementById('main')); 72 | -------------------------------------------------------------------------------- /examples/timeline/components/Page.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react'); 4 | var ReactCanvas = require('react-canvas'); 5 | 6 | var Group = ReactCanvas.Group; 7 | var Image = ReactCanvas.Image; 8 | var Text = ReactCanvas.Text; 9 | var FontFace = ReactCanvas.FontFace; 10 | var measureText = ReactCanvas.measureText; 11 | 12 | var CONTENT_INSET = 14; 13 | var TEXT_SCROLL_SPEED_MULTIPLIER = 0.6; 14 | var TEXT_ALPHA_SPEED_OUT_MULTIPLIER = 1.25; 15 | var TEXT_ALPHA_SPEED_IN_MULTIPLIER = 2.6; 16 | var IMAGE_LAYER_INDEX = 2; 17 | var TEXT_LAYER_INDEX = 1; 18 | 19 | var Page = React.createClass({ 20 | 21 | propTypes: { 22 | width: React.PropTypes.number.isRequired, 23 | height: React.PropTypes.number.isRequired, 24 | article: React.PropTypes.object.isRequired, 25 | scrollTop: React.PropTypes.number.isRequired 26 | }, 27 | 28 | componentWillMount: function () { 29 | // Pre-compute headline/excerpt text dimensions. 30 | var article = this.props.article; 31 | var maxWidth = this.props.width - 2 * CONTENT_INSET; 32 | var titleStyle = this.getTitleStyle(); 33 | var excerptStyle = this.getExcerptStyle(); 34 | this.titleMetrics = measureText(article.title, maxWidth, titleStyle.fontFace, titleStyle.fontSize, titleStyle.lineHeight); 35 | this.excerptMetrics = measureText(article.excerpt, maxWidth, excerptStyle.fontFace, excerptStyle.fontSize, excerptStyle.lineHeight); 36 | }, 37 | 38 | render: function () { 39 | var groupStyle = this.getGroupStyle(); 40 | var imageStyle = this.getImageStyle(); 41 | var titleStyle = this.getTitleStyle(); 42 | var excerptStyle = this.getExcerptStyle(); 43 | 44 | // Layout title and excerpt below image. 45 | titleStyle.height = this.titleMetrics.height; 46 | excerptStyle.top = titleStyle.top + titleStyle.height + CONTENT_INSET; 47 | excerptStyle.height = this.props.height - excerptStyle.top - CONTENT_INSET; 48 | 49 | return ( 50 | 51 | 52 | 53 | {this.props.article.title} 54 | {this.props.article.excerpt} 55 | 56 | 57 | ); 58 | }, 59 | 60 | // Styles 61 | // ====== 62 | 63 | getGroupStyle: function () { 64 | return { 65 | top: 0, 66 | left: 0, 67 | width: this.props.width, 68 | height: this.props.height, 69 | }; 70 | }, 71 | 72 | getImageHeight: function () { 73 | return Math.round(this.props.height * 0.5); 74 | }, 75 | 76 | getImageStyle: function () { 77 | return { 78 | top: 0, 79 | left: 0, 80 | width: this.props.width, 81 | height: this.getImageHeight(), 82 | backgroundColor: '#eee', 83 | zIndex: IMAGE_LAYER_INDEX 84 | }; 85 | }, 86 | 87 | getTitleStyle: function () { 88 | return { 89 | top: this.getImageHeight() + CONTENT_INSET, 90 | left: CONTENT_INSET, 91 | width: this.props.width - 2 * CONTENT_INSET, 92 | fontSize: 22, 93 | lineHeight: 30, 94 | fontFace: FontFace('Avenir Next Condensed, Helvetica, sans-serif', null, {weight: 500}) 95 | }; 96 | }, 97 | 98 | getExcerptStyle: function () { 99 | return { 100 | left: CONTENT_INSET, 101 | width: this.props.width - 2 * CONTENT_INSET, 102 | fontFace: FontFace('Georgia, serif'), 103 | fontSize: 15, 104 | lineHeight: 23 105 | }; 106 | }, 107 | 108 | getTextGroupStyle: function () { 109 | var imageHeight = this.getImageHeight(); 110 | var translateY = 0; 111 | var alphaMultiplier = (this.props.scrollTop <= 0) ? -TEXT_ALPHA_SPEED_OUT_MULTIPLIER : TEXT_ALPHA_SPEED_IN_MULTIPLIER; 112 | var alpha = 1 - (this.props.scrollTop / this.props.height) * alphaMultiplier; 113 | alpha = Math.min(Math.max(alpha, 0), 1); 114 | translateY = -this.props.scrollTop * TEXT_SCROLL_SPEED_MULTIPLIER; 115 | 116 | return { 117 | width: this.props.width, 118 | height: this.props.height - imageHeight, 119 | top: imageHeight, 120 | left: 0, 121 | alpha: alpha, 122 | translateY: translateY, 123 | zIndex: TEXT_LAYER_INDEX 124 | }; 125 | } 126 | 127 | }); 128 | 129 | module.exports = Page; 130 | -------------------------------------------------------------------------------- /examples/timeline/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ReactCanvas: Timeline 7 | 8 | 9 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var del = require('del'); 3 | var connect = require('gulp-connect'); 4 | var webpack = require('webpack-stream'); 5 | var webpackConfig = require('./webpack.config.js'); 6 | 7 | var port = process.env.PORT || 8080; 8 | var reloadPort = process.env.RELOAD_PORT || 35729; 9 | 10 | gulp.task('clean', function () { 11 | del(['build']); 12 | }); 13 | 14 | gulp.task('build', function () { 15 | return gulp.src(webpackConfig.entry.timeline[0]) 16 | .pipe(webpack(webpackConfig)) 17 | .pipe(gulp.dest('build/')); 18 | }); 19 | 20 | gulp.task('serve', function () { 21 | connect.server({ 22 | port: port, 23 | livereload: { 24 | port: reloadPort 25 | } 26 | }); 27 | }); 28 | 29 | gulp.task('reload-js', function () { 30 | return gulp.src('./build/*.js') 31 | .pipe(connect.reload()); 32 | }); 33 | 34 | gulp.task('watch', function () { 35 | gulp.watch(['./build/*.js'], ['reload-js']); 36 | }); 37 | 38 | gulp.task('default', ['clean', 'build', 'serve', 'watch']); 39 | -------------------------------------------------------------------------------- /lib/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 | 23 | getRawCanvas: function () { 24 | return this._canvas; 25 | }, 26 | 27 | getContext: function () { 28 | return this._canvas.getContext('2d'); 29 | } 30 | 31 | }); 32 | 33 | // PooledClass: 34 | 35 | // Be fairly conserative - we are potentially drawing a large number of medium 36 | // to large size images. 37 | Canvas.poolSize = 30; 38 | 39 | module.exports = Canvas; 40 | -------------------------------------------------------------------------------- /lib/CanvasUtils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var FontFace = require('./FontFace'); 4 | var clamp = require('./clamp'); 5 | var measureText = require('./measureText'); 6 | 7 | /** 8 | * Draw an image into a . This operation requires that the image 9 | * already be loaded. 10 | * 11 | * @param {CanvasContext} ctx 12 | * @param {Image} image The source image (from ImageCache.get()) 13 | * @param {Number} x The x-coordinate to begin drawing 14 | * @param {Number} y The y-coordinate to begin drawing 15 | * @param {Number} width The desired width 16 | * @param {Number} height The desired height 17 | * @param {Object} options Available options are: 18 | * {Number} originalWidth 19 | * {Number} originalHeight 20 | * {Object} focusPoint {x,y} 21 | * {String} backgroundColor 22 | */ 23 | function drawImage (ctx, image, x, y, width, height, options) { 24 | options = options || {}; 25 | 26 | if (options.backgroundColor) { 27 | ctx.save(); 28 | ctx.fillStyle = options.backgroundColor; 29 | ctx.fillRect(x, y, width, height); 30 | ctx.restore(); 31 | } 32 | 33 | var dx = 0; 34 | var dy = 0; 35 | var dw = 0; 36 | var dh = 0; 37 | var sx = 0; 38 | var sy = 0; 39 | var sw = 0; 40 | var sh = 0; 41 | var scale; 42 | var scaledSize; 43 | var actualSize; 44 | var focusPoint = options.focusPoint; 45 | 46 | actualSize = { 47 | width: image.getWidth(), 48 | height: image.getHeight() 49 | }; 50 | 51 | scale = Math.max( 52 | width / actualSize.width, 53 | height / actualSize.height 54 | ) || 1; 55 | scale = parseFloat(scale.toFixed(4), 10); 56 | 57 | scaledSize = { 58 | width: actualSize.width * scale, 59 | height: actualSize.height * scale 60 | }; 61 | 62 | if (focusPoint) { 63 | // Since image hints are relative to image "original" dimensions (original != actual), 64 | // use the original size for focal point cropping. 65 | if (options.originalHeight) { 66 | focusPoint.x *= (actualSize.height / options.originalHeight); 67 | focusPoint.y *= (actualSize.height / options.originalHeight); 68 | } 69 | } else { 70 | // Default focal point to [0.5, 0.5] 71 | focusPoint = { 72 | x: actualSize.width * 0.5, 73 | y: actualSize.height * 0.5 74 | }; 75 | } 76 | 77 | // Clip the image to rectangle (sx, sy, sw, sh). 78 | sx = Math.round(clamp(width * 0.5 - focusPoint.x * scale, width - scaledSize.width, 0)) * (-1 / scale); 79 | sy = Math.round(clamp(height * 0.5 - focusPoint.y * scale, height - scaledSize.height, 0)) * (-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 | var textMetrics; 111 | var currX = x; 112 | var currY = y; 113 | var currText; 114 | var options = options || {}; 115 | 116 | options.fontSize = options.fontSize || 16; 117 | options.lineHeight = options.lineHeight || 18; 118 | options.textAlign = options.textAlign || 'left'; 119 | options.backgroundColor = options.backgroundColor || 'transparent'; 120 | options.color = options.color || '#000'; 121 | 122 | textMetrics = measureText( 123 | text, 124 | width, 125 | fontFace, 126 | options.fontSize, 127 | options.lineHeight 128 | ); 129 | 130 | ctx.save(); 131 | 132 | // Draw the background 133 | if (options.backgroundColor !== 'transparent') { 134 | ctx.fillStyle = options.backgroundColor; 135 | ctx.fillRect(0, 0, width, height); 136 | } 137 | 138 | ctx.fillStyle = options.color; 139 | ctx.font = fontFace.attributes.style + ' ' + fontFace.attributes.weight + ' ' + options.fontSize + 'px ' + fontFace.family; 140 | 141 | textMetrics.lines.forEach(function (line, index) { 142 | currText = line.text; 143 | currY = (index === 0) ? y + options.fontSize : 144 | (y + options.fontSize + options.lineHeight * index); 145 | 146 | // Account for text-align: left|right|center 147 | switch (options.textAlign) { 148 | case 'center': 149 | currX = x + (width / 2) - (line.width / 2); 150 | break; 151 | case 'right': 152 | currX = x + width - line.width; 153 | break; 154 | default: 155 | currX = x; 156 | } 157 | 158 | if ((index < textMetrics.lines.length - 1) && 159 | ((options.fontSize + options.lineHeight * (index + 1)) > height)) { 160 | currText = currText.replace(/\,?\s?\w+$/, '…'); 161 | } 162 | 163 | if (currY <= (height + y)) { 164 | ctx.fillText(currText, currX, currY); 165 | } 166 | }); 167 | 168 | ctx.restore(); 169 | } 170 | 171 | /** 172 | * Draw a linear gradient 173 | * 174 | * @param {CanvasContext} ctx 175 | * @param {Number} x1 gradient start-x coordinate 176 | * @param {Number} y1 gradient start-y coordinate 177 | * @param {Number} x2 gradient end-x coordinate 178 | * @param {Number} y2 gradient end-y coordinate 179 | * @param {Array} colorStops Array of {(String)color, (Number)position} values 180 | * @param {Number} x x-coordinate to begin fill 181 | * @param {Number} y y-coordinate to begin fill 182 | * @param {Number} width how wide to fill 183 | * @param {Number} height how tall to fill 184 | */ 185 | function drawGradient(ctx, x1, y1, x2, y2, colorStops, x, y, width, height) { 186 | var grad; 187 | 188 | ctx.save(); 189 | grad = ctx.createLinearGradient(x1, y1, x2, y2); 190 | 191 | colorStops.forEach(function (colorStop) { 192 | grad.addColorStop(colorStop.position, colorStop.color); 193 | }); 194 | 195 | ctx.fillStyle = grad; 196 | ctx.fillRect(x, y, width, height); 197 | ctx.restore(); 198 | } 199 | 200 | module.exports = { 201 | drawImage: drawImage, 202 | drawText: drawText, 203 | drawGradient: drawGradient, 204 | }; 205 | 206 | -------------------------------------------------------------------------------- /lib/ContainerMixin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Adapted from ReactART: 4 | // https://github.com/reactjs/react-art 5 | 6 | var React = require('react'); 7 | var ReactMultiChild = require('react-dom/lib/ReactMultiChild'); 8 | var emptyObject = require('fbjs/lib/emptyObject'); 9 | 10 | var ContainerMixin = Object.assign({}, ReactMultiChild.Mixin, { 11 | 12 | /** 13 | * Moves a child component to the supplied index. 14 | * 15 | * @param {ReactComponent} child Component to move. 16 | * @param {number} toIndex Destination index of the element. 17 | * @protected 18 | */ 19 | moveChild: function(child, afterNode, toIndex, lastIndex) { 20 | var childNode = child._mountImage; 21 | var mostRecentlyPlacedChild = this._mostRecentlyPlacedChild; 22 | if (mostRecentlyPlacedChild == null) { 23 | // I'm supposed to be first. 24 | if (childNode.previousSibling) { 25 | if (this.node.firstChild) { 26 | childNode.injectBefore(this.node.firstChild); 27 | } else { 28 | childNode.inject(this.node); 29 | } 30 | } 31 | } else { 32 | // I'm supposed to be after the previous one. 33 | if (mostRecentlyPlacedChild.nextSibling !== childNode) { 34 | if (mostRecentlyPlacedChild.nextSibling) { 35 | childNode.injectBefore(mostRecentlyPlacedChild.nextSibling); 36 | } else { 37 | childNode.inject(this.node); 38 | } 39 | } 40 | } 41 | this._mostRecentlyPlacedChild = childNode; 42 | }, 43 | 44 | /** 45 | * Creates a child component. 46 | * 47 | * @param {ReactComponent} child Component to create. 48 | * @param {object} childNode ART node to insert. 49 | * @protected 50 | */ 51 | createChild: function(child, afterNode, childNode) { 52 | child._mountImage = childNode; 53 | var mostRecentlyPlacedChild = this._mostRecentlyPlacedChild; 54 | if (mostRecentlyPlacedChild == null) { 55 | // I'm supposed to be first. 56 | if (this.node.firstChild) { 57 | childNode.injectBefore(this.node.firstChild); 58 | } else { 59 | childNode.inject(this.node); 60 | } 61 | } else { 62 | // I'm supposed to be after the previous one. 63 | if (mostRecentlyPlacedChild.nextSibling) { 64 | childNode.injectBefore(mostRecentlyPlacedChild.nextSibling); 65 | } else { 66 | childNode.inject(this.node); 67 | } 68 | } 69 | this._mostRecentlyPlacedChild = childNode; 70 | }, 71 | 72 | /** 73 | * Removes a child component. 74 | * 75 | * @param {ReactComponent} child Child to remove. 76 | * @protected 77 | */ 78 | removeChild: function(child) { 79 | child._mountImage.remove(); 80 | child._mountImage = null; 81 | this.node.invalidateLayout(); 82 | }, 83 | 84 | updateChildrenAtRoot: function(nextChildren, transaction) { 85 | this.updateChildren(nextChildren, transaction, emptyObject); 86 | }, 87 | 88 | mountAndInjectChildrenAtRoot: function(children, transaction) { 89 | this.mountAndInjectChildren(children, transaction, emptyObject); 90 | }, 91 | 92 | /** 93 | * Override to bypass batch updating because it is not necessary. 94 | * 95 | * @param {?object} nextChildren. 96 | * @param {ReactReconcileTransaction} transaction 97 | * @internal 98 | * @override {ReactMultiChild.Mixin.updateChildren} 99 | */ 100 | updateChildren: function(nextChildren, transaction, context) { 101 | this._mostRecentlyPlacedChild = null; 102 | this._updateChildren(nextChildren, transaction, context); 103 | }, 104 | 105 | // Shorthands 106 | 107 | mountAndInjectChildren: function(children, transaction, context) { 108 | var mountedImages = this.mountChildren( 109 | children, 110 | transaction, 111 | context 112 | ); 113 | 114 | // Each mount image corresponds to one of the flattened children 115 | var i = 0; 116 | for (var key in this._renderedChildren) { 117 | if (this._renderedChildren.hasOwnProperty(key)) { 118 | var child = this._renderedChildren[key]; 119 | child._mountImage = mountedImages[i]; 120 | mountedImages[i].inject(this.node); 121 | i++; 122 | } 123 | } 124 | }, 125 | getHostNode: function () { return this.node }, 126 | getNativeNode: function () { return this.node }, 127 | 128 | }); 129 | 130 | module.exports = ContainerMixin; 131 | -------------------------------------------------------------------------------- /lib/DrawingUtils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var ImageCache = require('./ImageCache'); 4 | var FontUtils = require('./FontUtils'); 5 | var FontFace = require('./FontFace'); 6 | var FrameUtils = require('./FrameUtils'); 7 | var CanvasUtils = require('./CanvasUtils'); 8 | var Canvas = require('./Canvas'); 9 | 10 | // Global backing store cache 11 | var _backingStores = []; 12 | 13 | /** 14 | * Maintain a cache of backing for RenderLayer's which are accessible 15 | * through the RenderLayer's `backingStoreId` property. 16 | * 17 | * @param {String} id The unique `backingStoreId` for a RenderLayer 18 | * @return {HTMLCanvasElement} 19 | */ 20 | function getBackingStore (id) { 21 | for (var i=0, len=_backingStores.length; i < len; i++) { 22 | if (_backingStores[i].id === id) { 23 | return _backingStores[i].canvas; 24 | } 25 | } 26 | return null; 27 | } 28 | 29 | /** 30 | * Purge a layer's backing store from the cache. 31 | * 32 | * @param {String} id The layer's backingStoreId 33 | */ 34 | function invalidateBackingStore (id) { 35 | for (var i=0, len=_backingStores.length; i < len; i++) { 36 | if (_backingStores[i].id === id) { 37 | _backingStores.splice(i, 1); 38 | break; 39 | } 40 | } 41 | } 42 | 43 | /** 44 | * Purge the entire backing store cache. 45 | */ 46 | function invalidateAllBackingStores () { 47 | _backingStores = []; 48 | } 49 | 50 | /** 51 | * Find the nearest backing store ancestor for a given layer. 52 | * 53 | * @param {RenderLayer} layer 54 | */ 55 | function getBackingStoreAncestor (layer) { 56 | while (layer) { 57 | if (layer.backingStoreId) { 58 | return layer; 59 | } 60 | layer = layer.parentLayer; 61 | } 62 | return null; 63 | } 64 | 65 | /** 66 | * Check if a layer is using a given image URL. 67 | * 68 | * @param {RenderLayer} layer 69 | * @param {String} imageUrl 70 | * @return {Boolean} 71 | */ 72 | function layerContainsImage (layer, imageUrl) { 73 | // Check the layer itself. 74 | if (layer.type === 'image' && layer.imageUrl === imageUrl) { 75 | return layer; 76 | } 77 | 78 | // Check the layer's children. 79 | if (layer.children) { 80 | for (var i=0, len=layer.children.length; i < len; i++) { 81 | if (layerContainsImage(layer.children[i], imageUrl)) { 82 | return layer.children[i]; 83 | } 84 | } 85 | } 86 | 87 | return false; 88 | } 89 | 90 | /** 91 | * Check if a layer is using a given FontFace. 92 | * 93 | * @param {RenderLayer} layer 94 | * @param {FontFace} fontFace 95 | * @return {Boolean} 96 | */ 97 | function layerContainsFontFace (layer, fontFace) { 98 | // Check the layer itself. 99 | if (layer.type === 'text' && layer.fontFace && layer.fontFace.id === fontFace.id) { 100 | return layer; 101 | } 102 | 103 | // Check the layer's children. 104 | if (layer.children) { 105 | for (var i=0, len=layer.children.length; i < len; i++) { 106 | if (layerContainsFontFace(layer.children[i], fontFace)) { 107 | return layer.children[i]; 108 | } 109 | } 110 | } 111 | 112 | return false; 113 | } 114 | 115 | /** 116 | * Invalidates the backing stores for layers which contain an image layer 117 | * associated with the given imageUrl. 118 | * 119 | * @param {String} imageUrl 120 | */ 121 | function handleImageLoad (imageUrl) { 122 | _backingStores.forEach(function (backingStore) { 123 | if (layerContainsImage(backingStore.layer, imageUrl)) { 124 | invalidateBackingStore(backingStore.id); 125 | } 126 | }); 127 | } 128 | 129 | /** 130 | * Invalidates the backing stores for layers which contain a text layer 131 | * associated with the given font face. 132 | * 133 | * @param {FontFace} fontFace 134 | */ 135 | function handleFontLoad (fontFace) { 136 | _backingStores.forEach(function (backingStore) { 137 | if (layerContainsFontFace(backingStore.layer, fontFace)) { 138 | invalidateBackingStore(backingStore.id); 139 | } 140 | }); 141 | } 142 | 143 | /** 144 | * Draw a RenderLayer instance to a context. 145 | * 146 | * @param {CanvasRenderingContext2d} ctx 147 | * @param {RenderLayer} layer 148 | */ 149 | function drawRenderLayer (ctx, layer) { 150 | var customDrawFunc; 151 | 152 | // Performance: avoid drawing hidden layers. 153 | if (typeof layer.alpha === 'number' && layer.alpha <= 0) { 154 | return; 155 | } 156 | 157 | switch (layer.type) { 158 | case 'image': 159 | customDrawFunc = drawImageRenderLayer; 160 | break; 161 | 162 | case 'text': 163 | customDrawFunc = drawTextRenderLayer; 164 | break; 165 | 166 | case 'gradient': 167 | customDrawFunc = drawGradientRenderLayer; 168 | break; 169 | } 170 | 171 | // Establish drawing context for certain properties: 172 | // - alpha 173 | // - translate 174 | var saveContext = (layer.alpha !== null && layer.alpha < 1) || 175 | (layer.translateX || layer.translateY); 176 | 177 | if (saveContext) { 178 | ctx.save(); 179 | 180 | // Alpha: 181 | if (layer.alpha !== null && layer.alpha < 1) { 182 | ctx.globalAlpha = layer.alpha; 183 | } 184 | 185 | // Translation: 186 | if (layer.translateX || layer.translateY) { 187 | ctx.translate(layer.translateX || 0, layer.translateY || 0); 188 | } 189 | } 190 | 191 | // If the layer is bitmap-cacheable, draw in a pooled off-screen canvas. 192 | // We disable backing stores on pad since we flip there. 193 | if (layer.backingStoreId) { 194 | drawCacheableRenderLayer(ctx, layer, customDrawFunc); 195 | } else { 196 | // Draw default properties, such as background color. 197 | ctx.save(); 198 | drawBaseRenderLayer(ctx, layer); 199 | 200 | // Draw custom properties if needed. 201 | customDrawFunc && customDrawFunc(ctx, layer); 202 | ctx.restore(); 203 | 204 | // Draw child layers, sorted by their z-index. 205 | if (layer.children) { 206 | layer.children.slice().sort(sortByZIndexAscending).forEach(function (childLayer) { 207 | drawRenderLayer(ctx, childLayer); 208 | }); 209 | } 210 | } 211 | 212 | // Pop the context state if we established a new drawing context. 213 | if (saveContext) { 214 | ctx.restore(); 215 | } 216 | } 217 | 218 | /** 219 | * Draw base layer properties into a rendering context. 220 | * NOTE: The caller is responsible for calling save() and restore() as needed. 221 | * 222 | * @param {CanvasRenderingContext2d} ctx 223 | * @param {RenderLayer} layer 224 | */ 225 | function drawBaseRenderLayer (ctx, layer) { 226 | var frame = layer.frame; 227 | 228 | // Border radius: 229 | if (layer.borderRadius) { 230 | ctx.beginPath(); 231 | ctx.moveTo(frame.x + layer.borderRadius, frame.y); 232 | ctx.arcTo(frame.x + frame.width, frame.y, frame.x + frame.width, frame.y + frame.height, layer.borderRadius); 233 | ctx.arcTo(frame.x + frame.width, frame.y + frame.height, frame.x, frame.y + frame.height, layer.borderRadius); 234 | ctx.arcTo(frame.x, frame.y + frame.height, frame.x, frame.y, layer.borderRadius); 235 | ctx.arcTo(frame.x, frame.y, frame.x + frame.width, frame.y, layer.borderRadius); 236 | ctx.closePath(); 237 | 238 | // Create a clipping path when drawing an image or using border radius. 239 | if (layer.type === 'image') { 240 | ctx.clip(); 241 | } 242 | 243 | // Border with border radius: 244 | if (layer.borderColor) { 245 | ctx.lineWidth = layer.borderWidth || 1; 246 | ctx.strokeStyle = layer.borderColor; 247 | ctx.stroke(); 248 | } 249 | } 250 | 251 | // Border color (no border radius): 252 | if (layer.borderColor && !layer.borderRadius) { 253 | ctx.lineWidth = layer.borderWidth || 1; 254 | ctx.strokeStyle = layer.borderColor; 255 | ctx.strokeRect(frame.x, frame.y, frame.width, frame.height); 256 | } 257 | 258 | // Shadow: 259 | ctx.shadowBlur = layer.shadowBlur; 260 | ctx.shadowColor = layer.shadowColor; 261 | ctx.shadowOffsetX = layer.shadowOffsetX; 262 | ctx.shadowOffsetY = layer.shadowOffsetY; 263 | 264 | // Background color: 265 | if (layer.backgroundColor) { 266 | ctx.fillStyle = layer.backgroundColor; 267 | if (layer.borderRadius) { 268 | // Fill the current path when there is a borderRadius set. 269 | ctx.fill(); 270 | } else { 271 | ctx.fillRect(frame.x, frame.y, frame.width, frame.height); 272 | } 273 | } 274 | } 275 | 276 | /** 277 | * Draw a bitmap-cacheable layer into a pooled . The result will be 278 | * drawn into the given context. This will populate the layer backing store 279 | * cache with the result. 280 | * 281 | * @param {CanvasRenderingContext2d} ctx 282 | * @param {RenderLayer} layer 283 | * @param {Function} customDrawFunc 284 | * @private 285 | */ 286 | function drawCacheableRenderLayer (ctx, layer, customDrawFunc) { 287 | // See if there is a pre-drawn canvas in the pool. 288 | var backingStore = getBackingStore(layer.backingStoreId); 289 | var backingStoreScale = layer.scale || window.devicePixelRatio; 290 | var frameOffsetY = layer.frame.y; 291 | var frameOffsetX = layer.frame.x; 292 | var backingContext; 293 | 294 | if (!backingStore) { 295 | if (_backingStores.length >= Canvas.poolSize) { 296 | // Re-use the oldest backing store once we reach the pooling limit. 297 | backingStore = _backingStores[0].canvas; 298 | Canvas.call(backingStore, layer.frame.width, layer.frame.height, backingStoreScale); 299 | 300 | // Move the re-use canvas to the front of the queue. 301 | _backingStores[0].id = layer.backingStoreId; 302 | _backingStores[0].canvas = backingStore; 303 | _backingStores.push(_backingStores.shift()); 304 | } else { 305 | // Create a new backing store, we haven't yet reached the pooling limit 306 | backingStore = new Canvas(layer.frame.width, layer.frame.height, backingStoreScale); 307 | _backingStores.push({ 308 | id: layer.backingStoreId, 309 | layer: layer, 310 | canvas: backingStore 311 | }); 312 | } 313 | 314 | // Draw into the backing at (0, 0) - we will later use the 315 | // to draw the layer as an image at the proper coordinates. 316 | backingContext = backingStore.getContext('2d'); 317 | layer.translate(-frameOffsetX, -frameOffsetY); 318 | 319 | // Draw default properties, such as background color. 320 | backingContext.save(); 321 | drawBaseRenderLayer(backingContext, layer); 322 | 323 | // Custom drawing operations 324 | customDrawFunc && customDrawFunc(backingContext, layer); 325 | backingContext.restore(); 326 | 327 | // Draw child layers, sorted by their z-index. 328 | if (layer.children) { 329 | layer.children.slice().sort(sortByZIndexAscending).forEach(function (childLayer) { 330 | drawRenderLayer(backingContext, childLayer); 331 | }); 332 | } 333 | 334 | // Restore layer's original frame. 335 | layer.translate(frameOffsetX, frameOffsetY); 336 | } 337 | 338 | // We have the pre-rendered canvas ready, draw it into the destination canvas. 339 | if (layer.clipRect) { 340 | // Fill the clipping rect in the destination canvas. 341 | var sx = (layer.clipRect.x - layer.frame.x) * backingStoreScale; 342 | var sy = (layer.clipRect.y - layer.frame.y) * backingStoreScale; 343 | var sw = layer.clipRect.width * backingStoreScale; 344 | var sh = layer.clipRect.height * backingStoreScale; 345 | var dx = layer.clipRect.x; 346 | var dy = layer.clipRect.y; 347 | var dw = layer.clipRect.width; 348 | var dh = layer.clipRect.height; 349 | 350 | // No-op for zero size rects. iOS / Safari will throw an exception. 351 | if (sw > 0 && sh > 0) { 352 | ctx.drawImage(backingStore.getRawCanvas(), sx, sy, sw, sh, dx, dy, dw, dh); 353 | } 354 | } else { 355 | // Fill the entire canvas 356 | ctx.drawImage(backingStore.getRawCanvas(), layer.frame.x, layer.frame.y, layer.frame.width, layer.frame.height); 357 | } 358 | } 359 | 360 | /** 361 | * @private 362 | */ 363 | function sortByZIndexAscending (layerA, layerB) { 364 | return (layerA.zIndex || 0) - (layerB.zIndex || 0); 365 | } 366 | 367 | /** 368 | * @private 369 | */ 370 | function drawImageRenderLayer (ctx, layer) { 371 | if (!layer.imageUrl) { 372 | return; 373 | } 374 | 375 | // Don't draw until loaded 376 | var image = ImageCache.get(layer.imageUrl); 377 | if (!image.isLoaded()) { 378 | return; 379 | } 380 | 381 | CanvasUtils.drawImage(ctx, image, layer.frame.x, layer.frame.y, layer.frame.width, layer.frame.height); 382 | } 383 | 384 | /** 385 | * @private 386 | */ 387 | function drawTextRenderLayer (ctx, layer) { 388 | // Fallback to standard font. 389 | var fontFace = layer.fontFace || FontFace.Default(); 390 | 391 | // Don't draw text until loaded 392 | if (!FontUtils.isFontLoaded(fontFace)) { 393 | return; 394 | } 395 | 396 | CanvasUtils.drawText(ctx, layer.text, layer.frame.x, layer.frame.y, layer.frame.width, layer.frame.height, fontFace, { 397 | fontSize: layer.fontSize, 398 | lineHeight: layer.lineHeight, 399 | textAlign: layer.textAlign, 400 | color: layer.color 401 | }); 402 | } 403 | 404 | /** 405 | * @private 406 | */ 407 | function drawGradientRenderLayer (ctx, layer) { 408 | // Default to linear gradient from top to bottom. 409 | var x1 = layer.x1 || layer.frame.x; 410 | var y1 = layer.y1 || layer.frame.y; 411 | var x2 = layer.x2 || layer.frame.x; 412 | var y2 = layer.y2 || layer.frame.y + layer.frame.height; 413 | CanvasUtils.drawGradient(ctx, x1, y1, x2, y2, layer.colorStops, layer.frame.x, layer.frame.y, layer.frame.width, layer.frame.height); 414 | } 415 | 416 | module.exports = { 417 | drawRenderLayer: drawRenderLayer, 418 | invalidateBackingStore: invalidateBackingStore, 419 | invalidateAllBackingStores: invalidateAllBackingStores, 420 | handleImageLoad: handleImageLoad, 421 | handleFontLoad: handleFontLoad, 422 | layerContainsImage: layerContainsImage, 423 | layerContainsFontFace: layerContainsFontFace 424 | }; 425 | -------------------------------------------------------------------------------- /lib/Easing.js: -------------------------------------------------------------------------------- 1 | // Penner easing equations 2 | // https://gist.github.com/gre/1650294 3 | 4 | var Easing = { 5 | 6 | linear: function (t) { 7 | return t; 8 | }, 9 | 10 | easeInQuad: function (t) { 11 | return Math.pow(t, 2); 12 | }, 13 | 14 | easeOutQuad: function (t) { 15 | return t * (2-t); 16 | }, 17 | 18 | easeInOutQuad: function (t) { 19 | return t < .5 ? 2 * t * t : -1 + (4 - 2 * t) * t; 20 | }, 21 | 22 | easeInCubic: function (t) { 23 | return t * t * t; 24 | }, 25 | 26 | easeOutCubic: function (t) { 27 | return (--t) * t * t + 1; 28 | }, 29 | 30 | easeInOutCubic: function (t) { 31 | return t < .5 ? 4 * t * t * t : (t-1) * (2*t - 2) * (2*t - 2) + 1; 32 | } 33 | 34 | }; 35 | 36 | module.exports = Easing; 37 | -------------------------------------------------------------------------------- /lib/EventTypes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Supported events that RenderLayer's can subscribe to. 4 | 5 | module.exports = { 6 | onTouchStart: 'touchstart', 7 | onTouchMove: 'touchmove', 8 | onTouchEnd: 'touchend', 9 | onTouchCancel: 'touchcancel', 10 | onClick: 'click', 11 | onContextMenu: 'contextmenu', 12 | onDoubleClick: 'dblclick' 13 | }; 14 | -------------------------------------------------------------------------------- /lib/FontFace.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _fontFaces = {}; 4 | 5 | /** 6 | * @param {String} family The CSS font-family value 7 | * @param {String} url The remote URL for the font file 8 | * @param {Object} attributes Font attributes supported: style, weight 9 | * @return {Object} 10 | */ 11 | function FontFace (family, url, attributes) { 12 | var fontFace; 13 | var fontId; 14 | 15 | attributes = attributes || {}; 16 | attributes.style = attributes.style || 'normal'; 17 | attributes.weight = attributes.weight || 400; 18 | 19 | fontId = getCacheKey(family, url, attributes); 20 | fontFace = _fontFaces[fontId]; 21 | 22 | if (!fontFace) { 23 | fontFace = {}; 24 | fontFace.id = fontId; 25 | fontFace.family = family; 26 | fontFace.url = url; 27 | fontFace.attributes = attributes; 28 | _fontFaces[fontId] = fontFace; 29 | } 30 | 31 | return fontFace; 32 | } 33 | 34 | /** 35 | * Helper for retrieving the default family by weight. 36 | * 37 | * @param {Number} fontWeight 38 | * @return {FontFace} 39 | */ 40 | FontFace.Default = function (fontWeight) { 41 | return FontFace('sans-serif', null, {weight: fontWeight}); 42 | }; 43 | 44 | /** 45 | * @internal 46 | */ 47 | function getCacheKey (family, url, attributes) { 48 | return family + url + Object.keys(attributes).sort().map(function (key) { 49 | return attributes[key]; 50 | }); 51 | } 52 | 53 | module.exports = FontFace; 54 | -------------------------------------------------------------------------------- /lib/FontUtils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var FontFace = require('./FontFace'); 4 | 5 | var _useNativeImpl = (typeof window.FontFace !== 'undefined'); 6 | var _pendingFonts = {}; 7 | var _loadedFonts = {}; 8 | var _failedFonts = {}; 9 | 10 | var kFontLoadTimeout = 3000; 11 | 12 | /** 13 | * Check if a font face has loaded 14 | * @param {FontFace} fontFace 15 | * @return {Boolean} 16 | */ 17 | function isFontLoaded (fontFace) { 18 | // For remote URLs, check the cache. System fonts (sans url) assume loaded. 19 | return _loadedFonts[fontFace.id] !== undefined || !fontFace.url; 20 | } 21 | 22 | /** 23 | * Load a remote font and execute a callback. 24 | * @param {FontFace} fontFace The font to Load 25 | * @param {Function} callback Function executed upon font Load 26 | */ 27 | function loadFont (fontFace, callback) { 28 | var defaultNode; 29 | var testNode; 30 | var checkFont; 31 | 32 | // See if we've previously loaded it. 33 | if (_loadedFonts[fontFace.id]) { 34 | return callback(null); 35 | } 36 | 37 | // See if we've previously failed to load it. 38 | if (_failedFonts[fontFace.id]) { 39 | return callback(_failedFonts[fontFace.id]); 40 | } 41 | 42 | // System font: assume already loaded. 43 | if (!fontFace.url) { 44 | return callback(null); 45 | } 46 | 47 | // Font load is already in progress: 48 | if (_pendingFonts[fontFace.id]) { 49 | _pendingFonts[fontFace.id].callbacks.push(callback); 50 | return; 51 | } 52 | 53 | // Create the test 's for measuring. 54 | defaultNode = createTestNode('Helvetica', fontFace.attributes); 55 | testNode = createTestNode(fontFace.family, fontFace.attributes); 56 | document.body.appendChild(testNode); 57 | document.body.appendChild(defaultNode); 58 | 59 | _pendingFonts[fontFace.id] = { 60 | startTime: Date.now(), 61 | defaultNode: defaultNode, 62 | testNode: testNode, 63 | callbacks: [callback] 64 | }; 65 | 66 | // Font watcher 67 | checkFont = function () { 68 | var currWidth = testNode.getBoundingClientRect().width; 69 | var defaultWidth = defaultNode.getBoundingClientRect().width; 70 | var loaded = currWidth !== defaultWidth; 71 | 72 | if (loaded) { 73 | handleFontLoad(fontFace, null); 74 | } else { 75 | // Timeout? 76 | if (Date.now() - _pendingFonts[fontFace.id].startTime >= kFontLoadTimeout) { 77 | handleFontLoad(fontFace, true); 78 | } else { 79 | requestAnimationFrame(checkFont); 80 | } 81 | } 82 | }; 83 | 84 | // Start watching 85 | checkFont(); 86 | } 87 | 88 | // Internal 89 | // ======== 90 | 91 | /** 92 | * Native FontFace loader implementation 93 | * @internal 94 | */ 95 | function loadFontNative (fontFace, callback) { 96 | var theFontFace; 97 | 98 | // See if we've previously loaded it. 99 | if (_loadedFonts[fontFace.id]) { 100 | return callback(null); 101 | } 102 | 103 | // See if we've previously failed to load it. 104 | if (_failedFonts[fontFace.id]) { 105 | return callback(_failedFonts[fontFace.id]); 106 | } 107 | 108 | // System font: assume it's installed. 109 | if (!fontFace.url) { 110 | return callback(null); 111 | } 112 | 113 | // Font load is already in progress: 114 | if (_pendingFonts[fontFace.id]) { 115 | _pendingFonts[fontFace.id].callbacks.push(callback); 116 | return; 117 | } 118 | 119 | _pendingFonts[fontFace.id] = { 120 | startTime: Date.now(), 121 | callbacks: [callback] 122 | }; 123 | 124 | // Use font loader API 125 | theFontFace = new window.FontFace(fontFace.family, 126 | 'url(' + fontFace.url + ')', fontFace.attributes); 127 | 128 | theFontFace.load().then(function () { 129 | _loadedFonts[fontFace.id] = true; 130 | callback(null); 131 | }, function (err) { 132 | _failedFonts[fontFace.id] = err; 133 | callback(err); 134 | }); 135 | } 136 | 137 | /** 138 | * Helper method for created a hidden with a given font. 139 | * Uses TypeKit's default test string, which is said to result 140 | * in highly varied measured widths when compared to the default font. 141 | * @internal 142 | */ 143 | function createTestNode (family, attributes) { 144 | var span = document.createElement('span'); 145 | span.setAttribute('data-fontfamily', family); 146 | span.style.cssText = 'position:absolute; left:-5000px; top:-5000px; visibility:hidden;' + 147 | 'font-size:100px; font-family:"' + family + '", Helvetica;font-weight: ' + attributes.weight + ';' + 148 | 'font-style:' + attributes.style + ';'; 149 | span.innerHTML = 'BESs'; 150 | return span; 151 | } 152 | 153 | /** 154 | * @internal 155 | */ 156 | function handleFontLoad (fontFace, timeout) { 157 | var error = timeout ? 'Exceeded load timeout of ' + kFontLoadTimeout + 'ms' : null; 158 | 159 | if (!error) { 160 | _loadedFonts[fontFace.id] = true; 161 | } else { 162 | _failedFonts[fontFace.id] = error; 163 | } 164 | 165 | // Execute pending callbacks. 166 | _pendingFonts[fontFace.id].callbacks.forEach(function (callback) { 167 | callback(error); 168 | }); 169 | 170 | // Clean up DOM 171 | if (_pendingFonts[fontFace.id].defaultNode) { 172 | document.body.removeChild(_pendingFonts[fontFace.id].defaultNode); 173 | } 174 | if (_pendingFonts[fontFace.id].testNode) { 175 | document.body.removeChild(_pendingFonts[fontFace.id].testNode); 176 | } 177 | 178 | // Clean up waiting queue 179 | delete _pendingFonts[fontFace.id]; 180 | } 181 | 182 | module.exports = { 183 | isFontLoaded: isFontLoaded, 184 | loadFont: _useNativeImpl ? loadFontNative : loadFont 185 | }; 186 | -------------------------------------------------------------------------------- /lib/FrameUtils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function Frame (x, y, width, height) { 4 | this.x = x; 5 | this.y = y; 6 | this.width = width; 7 | this.height = height; 8 | } 9 | 10 | /** 11 | * Get a frame object 12 | * 13 | * @param {Number} x 14 | * @param {Number} y 15 | * @param {Number} width 16 | * @param {Number} height 17 | * @return {Frame} 18 | */ 19 | function make (x, y, width, height) { 20 | return new Frame(x, y, width, height); 21 | } 22 | 23 | /** 24 | * Return a zero size anchored at (0, 0). 25 | * 26 | * @return {Frame} 27 | */ 28 | function zero () { 29 | return make(0, 0, 0, 0); 30 | } 31 | 32 | /** 33 | * Return a cloned frame 34 | * 35 | * @param {Frame} frame 36 | * @return {Frame} 37 | */ 38 | function clone (frame) { 39 | return make(frame.x, frame.y, frame.width, frame.height); 40 | } 41 | 42 | /** 43 | * Creates a new frame by a applying edge insets. This method accepts CSS 44 | * shorthand notation e.g. inset(myFrame, 10, 0); 45 | * 46 | * @param {Frame} frame 47 | * @param {Number} top 48 | * @param {Number} right 49 | * @param {?Number} bottom 50 | * @param {?Number} left 51 | * @return {Frame} 52 | */ 53 | function inset (frame, top, right, bottom, left) { 54 | var frameCopy = clone(frame); 55 | 56 | // inset(myFrame, 10, 0) => inset(myFrame, 10, 0, 10, 0) 57 | if (typeof bottom === 'undefined') { 58 | bottom = top; 59 | left = right; 60 | } 61 | 62 | // inset(myFrame, 10) => inset(myFrame, 10, 10, 10, 10) 63 | if (typeof right === 'undefined') { 64 | right = bottom = left = top; 65 | } 66 | 67 | frameCopy.x += left; 68 | frameCopy.y += top; 69 | frameCopy.height -= (top + bottom); 70 | frameCopy.width -= (left + right); 71 | 72 | return frameCopy; 73 | } 74 | 75 | /** 76 | * Compute the intersection region between 2 frames. 77 | * 78 | * @param {Frame} frame 79 | * @param {Frame} otherFrame 80 | * @return {Frame} 81 | */ 82 | function intersection (frame, otherFrame) { 83 | var x = Math.max(frame.x, otherFrame.x); 84 | var width = Math.min(frame.x + frame.width, otherFrame.x + otherFrame.width); 85 | var y = Math.max(frame.y, otherFrame.y); 86 | var height = Math.min(frame.y + frame.height, otherFrame.y + otherFrame.height); 87 | if (width >= x && height >= y) { 88 | return make(x, y, width - x, height - y); 89 | } 90 | return null; 91 | } 92 | 93 | /** 94 | * Compute the union of two frames 95 | * 96 | * @param {Frame} frame 97 | * @param {Frame} otherFrame 98 | * @return {Frame} 99 | */ 100 | function union (frame, otherFrame) { 101 | var x1 = Math.min(frame.x, otherFrame.x); 102 | var x2 = Math.max(frame.x + frame.width, otherFrame.x + otherFrame.width); 103 | var y1 = Math.min(frame.y, otherFrame.y); 104 | var y2 = Math.max(frame.y + frame.height, otherFrame.y + otherFrame.height); 105 | return make(x1, y1, x2 - x1, y2 - y1); 106 | } 107 | 108 | /** 109 | * Determine if 2 frames intersect each other 110 | * 111 | * @param {Frame} frame 112 | * @param {Frame} otherFrame 113 | * @return {Boolean} 114 | */ 115 | function intersects (frame, otherFrame) { 116 | return !(otherFrame.x > frame.x + frame.width || 117 | otherFrame.x + otherFrame.width < frame.x || 118 | otherFrame.y > frame.y + frame.height || 119 | otherFrame.y + otherFrame.height < frame.y); 120 | } 121 | 122 | module.exports = { 123 | make: make, 124 | zero: zero, 125 | clone: clone, 126 | inset: inset, 127 | intersection: intersection, 128 | intersects: intersects, 129 | union: union 130 | }; 131 | 132 | -------------------------------------------------------------------------------- /lib/Gradient.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react'); 4 | var createComponent = require('./createComponent'); 5 | var LayerMixin = require('./LayerMixin'); 6 | 7 | var Gradient = createComponent('Gradient', LayerMixin, { 8 | 9 | applyGradientProps: function (prevProps, props) { 10 | var layer = this.node; 11 | layer.type = 'gradient'; 12 | layer.colorStops = props.colorStops || []; 13 | this.applyLayerProps(prevProps, props); 14 | }, 15 | 16 | mountComponent: function ( 17 | transaction, 18 | nativeParent, 19 | nativeContainerInfo, 20 | context 21 | ) { 22 | var props = this._currentElement.props; 23 | var layer = this.node; 24 | this.applyGradientProps({}, props); 25 | return layer; 26 | }, 27 | 28 | receiveComponent: function (nextComponent, transaction, context) { 29 | var prevProps = this._currentElement.props; 30 | var props = nextComponent.props; 31 | this.applyGradientProps({}, props); 32 | this._currentElement = nextComponent; 33 | this.node.invalidateLayout(); 34 | }, 35 | 36 | }); 37 | 38 | 39 | module.exports = Gradient; 40 | -------------------------------------------------------------------------------- /lib/Group.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var createComponent = require('./createComponent'); 4 | var ContainerMixin = require('./ContainerMixin'); 5 | var LayerMixin = require('./LayerMixin'); 6 | var RenderLayer = require('./RenderLayer'); 7 | 8 | var Group = createComponent('Group', LayerMixin, ContainerMixin, { 9 | 10 | mountComponent: function ( 11 | transaction, 12 | nativeParent, 13 | nativeContainerInfo, 14 | context 15 | ) { 16 | var props = this._currentElement.props; 17 | var layer = this.node; 18 | 19 | this.applyLayerProps({}, props); 20 | this.mountAndInjectChildren(props.children, transaction, context); 21 | 22 | return layer; 23 | }, 24 | 25 | receiveComponent: function (nextComponent, transaction, context) { 26 | var props = nextComponent.props; 27 | var prevProps = this._currentElement.props; 28 | this.applyLayerProps(prevProps, props); 29 | this.updateChildren(props.children, transaction, context); 30 | this._currentElement = nextComponent; 31 | this.node.invalidateLayout(); 32 | }, 33 | 34 | unmountComponent: function () { 35 | LayerMixin.unmountComponent.call(this); 36 | this.unmountChildren(); 37 | } 38 | 39 | }); 40 | 41 | module.exports = Group; 42 | -------------------------------------------------------------------------------- /lib/Image.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react'); 4 | var createComponent = require('./createComponent'); 5 | var LayerMixin = require('./LayerMixin'); 6 | var Layer = require('./Layer'); 7 | var Group = require('./Group'); 8 | var ImageCache = require('./ImageCache'); 9 | var Easing = require('./Easing'); 10 | var clamp = require('./clamp'); 11 | 12 | var FADE_DURATION = 200; 13 | 14 | var RawImage = createComponent('Image', LayerMixin, { 15 | 16 | applyImageProps: function (prevProps, props) { 17 | var layer = this.node; 18 | 19 | layer.type = 'image'; 20 | layer.imageUrl = props.src; 21 | }, 22 | 23 | mountComponent: function ( 24 | transaction, 25 | nativeParent, 26 | nativeContainerInfo, 27 | context 28 | ) { 29 | var props = this._currentElement.props; 30 | var layer = this.node; 31 | this.applyLayerProps({}, props); 32 | this.applyImageProps({}, props); 33 | return layer; 34 | }, 35 | 36 | receiveComponent: function (nextComponent, transaction, context) { 37 | var prevProps = this._currentElement.props; 38 | var props = nextComponent.props; 39 | this.applyLayerProps(prevProps, props); 40 | this.applyImageProps(prevProps, props); 41 | this._currentElement = nextComponent; 42 | this.node.invalidateLayout(); 43 | }, 44 | 45 | }); 46 | 47 | var Image = React.createClass({ 48 | 49 | propTypes: { 50 | src: React.PropTypes.string.isRequired, 51 | style: React.PropTypes.object, 52 | useBackingStore: React.PropTypes.bool, 53 | fadeIn: React.PropTypes.bool, 54 | fadeInDuration: React.PropTypes.number 55 | }, 56 | 57 | getInitialState: function () { 58 | var loaded = ImageCache.get(this.props.src).isLoaded(); 59 | return { 60 | loaded: loaded, 61 | imageAlpha: loaded ? 1 : 0 62 | }; 63 | }, 64 | 65 | componentDidMount: function () { 66 | ImageCache.get(this.props.src).on('load', this.handleImageLoad); 67 | }, 68 | 69 | componentWillUpdate: function(nextProps, nextState) { 70 | if(nextProps.src !== this.props.src) { 71 | ImageCache.get(this.props.src).removeListener('load', this.handleImageLoad); 72 | ImageCache.get(nextProps.src).on('load', this.handleImageLoad); 73 | var loaded = ImageCache.get(nextProps.src).isLoaded(); 74 | this.setState({loaded: loaded}); 75 | } 76 | }, 77 | 78 | componentWillUnmount: function () { 79 | if (this._pendingAnimationFrame) { 80 | cancelAnimationFrame(this._pendingAnimationFrame); 81 | } 82 | ImageCache.get(this.props.src).removeListener('load', this.handleImageLoad); 83 | }, 84 | 85 | componentDidUpdate: function (prevProps, prevState) { 86 | if (this.refs.image) { 87 | this.refs.image.invalidateLayout(); 88 | } 89 | }, 90 | 91 | render: function () { 92 | var rawImage; 93 | var imageStyle = Object.assign({}, this.props.style); 94 | var style = Object.assign({}, this.props.style); 95 | var backgroundStyle = Object.assign({}, this.props.style); 96 | var useBackingStore = this.state.loaded ? this.props.useBackingStore : false; 97 | 98 | // Hide the image until loaded. 99 | imageStyle.alpha = this.state.imageAlpha; 100 | 101 | // Hide opaque background if image loaded so that images with transparent 102 | // do not render on top of solid color. 103 | style.backgroundColor = imageStyle.backgroundColor = null; 104 | backgroundStyle.alpha = clamp(1 - this.state.imageAlpha, 0, 1); 105 | 106 | return ( 107 | React.createElement(Group, {ref: 'main', style: style}, 108 | React.createElement(Layer, {ref: 'background', style: backgroundStyle}), 109 | React.createElement(RawImage, {ref: 'image', src: this.props.src, style: imageStyle, useBackingStore: useBackingStore}) 110 | ) 111 | ); 112 | }, 113 | 114 | handleImageLoad: function () { 115 | var imageAlpha = 1; 116 | if (this.props.fadeIn) { 117 | imageAlpha = 0; 118 | this._animationStartTime = Date.now(); 119 | this._pendingAnimationFrame = requestAnimationFrame(this.stepThroughAnimation); 120 | } 121 | this.setState({ loaded: true, imageAlpha: imageAlpha }); 122 | }, 123 | 124 | stepThroughAnimation: function () { 125 | var fadeInDuration = this.props.fadeInDuration || FADE_DURATION; 126 | var alpha = Easing.easeInCubic((Date.now() - this._animationStartTime) / fadeInDuration); 127 | alpha = clamp(alpha, 0, 1); 128 | this.setState({ imageAlpha: alpha }); 129 | if (alpha < 1) { 130 | this._pendingAnimationFrame = requestAnimationFrame(this.stepThroughAnimation); 131 | } 132 | } 133 | 134 | }); 135 | 136 | module.exports = Image; 137 | -------------------------------------------------------------------------------- /lib/ImageCache.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var EventEmitter = require('events'); 4 | 5 | var NOOP = function () {}; 6 | 7 | function Img (src) { 8 | this._originalSrc = src; 9 | this._img = new Image(); 10 | this._img.onload = this.emit.bind(this, 'load'); 11 | this._img.onerror = this.emit.bind(this, 'error'); 12 | this._img.crossOrigin = true; 13 | this._img.src = src; 14 | 15 | // The default impl of events emitter will throw on any 'error' event unless 16 | // there is at least 1 handler. Logging anything in this case is unnecessary 17 | // since the browser console will log it too. 18 | this.on('error', NOOP); 19 | 20 | // Default is just 10. 21 | this.setMaxListeners(100); 22 | } 23 | 24 | Object.assign(Img.prototype, EventEmitter.prototype, { 25 | 26 | /** 27 | * Pooling owner looks for this 28 | */ 29 | destructor: function () { 30 | // Make sure we aren't leaking callbacks. 31 | this.removeAllListeners(); 32 | }, 33 | 34 | /** 35 | * Retrieve the original image URL before browser normalization 36 | * 37 | * @return {String} 38 | */ 39 | getOriginalSrc: function () { 40 | return this._originalSrc; 41 | }, 42 | 43 | /** 44 | * Retrieve a reference to the underyling node. 45 | * 46 | * @return {HTMLImageElement} 47 | */ 48 | getRawImage: function () { 49 | return this._img; 50 | }, 51 | 52 | /** 53 | * Retrieve the loaded image width 54 | * 55 | * @return {Number} 56 | */ 57 | getWidth: function () { 58 | return this._img.naturalWidth; 59 | }, 60 | 61 | /** 62 | * Retrieve the loaded image height 63 | * 64 | * @return {Number} 65 | */ 66 | getHeight: function () { 67 | return this._img.naturalHeight; 68 | }, 69 | 70 | /** 71 | * @return {Bool} 72 | */ 73 | isLoaded: function () { 74 | return this._img.naturalHeight > 0; 75 | } 76 | 77 | }); 78 | 79 | var kInstancePoolLength = 300; 80 | 81 | var _instancePool = { 82 | length: 0, 83 | // Keep all the nodes in memory. 84 | elements: { 85 | 86 | }, 87 | 88 | // Push with 0 frequency 89 | push: function (hash, data) { 90 | this.length++; 91 | this.elements[hash] = { 92 | hash: hash, // Helps identifying 93 | freq: 0, 94 | data: data 95 | }; 96 | }, 97 | 98 | get: function (path) { 99 | var element = this.elements[path]; 100 | 101 | if( element ){ 102 | element.freq++; 103 | return element.data; 104 | } 105 | 106 | return null; 107 | }, 108 | 109 | // used to explicitely remove the path 110 | removeElement: function (path) { 111 | // Now almighty GC can claim this soul 112 | var element = this.elements[path]; 113 | delete this.elements[path]; 114 | this.length--; 115 | return element; 116 | }, 117 | 118 | _reduceLeastUsed: function (least, currentHash) { 119 | var current = _instancePool.elements[currentHash]; 120 | 121 | if( least.freq > current.freq ){ 122 | return current; 123 | } 124 | 125 | return least; 126 | }, 127 | 128 | popLeastUsed: function () { 129 | var reducer = _instancePool._reduceLeastUsed; 130 | var minUsed = Object.keys(this.elements).reduce(reducer, { freq: Infinity }); 131 | 132 | if( minUsed.hash ){ 133 | return this.removeElement(minUsed.hash); 134 | } 135 | 136 | return null; 137 | } 138 | }; 139 | 140 | var ImageCache = { 141 | 142 | /** 143 | * Retrieve an image from the cache 144 | * 145 | * @return {Img} 146 | */ 147 | get: function (src) { 148 | var image = _instancePool.get(src); 149 | if (!image) { 150 | // Awesome LRU 151 | image = new Img(src); 152 | if (_instancePool.length >= kInstancePoolLength) { 153 | _instancePool.popLeastUsed().destructor(); 154 | } 155 | _instancePool.push(image.getOriginalSrc(), image); 156 | } 157 | return image; 158 | } 159 | 160 | }; 161 | 162 | module.exports = ImageCache; 163 | -------------------------------------------------------------------------------- /lib/Layer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var createComponent = require('./createComponent'); 4 | var LayerMixin = require('./LayerMixin'); 5 | 6 | var Layer = createComponent('Layer', LayerMixin, { 7 | 8 | mountComponent: function ( 9 | transaction, 10 | nativeParent, 11 | nativeContainerInfo, 12 | context 13 | ) { 14 | var props = this._currentElement.props; 15 | var layer = this.node; 16 | this.applyLayerProps({}, props); 17 | return layer; 18 | }, 19 | 20 | receiveComponent: function (nextComponent, transaction, context) { 21 | var prevProps = this._currentElement.props; 22 | var props = nextComponent.props; 23 | this.applyLayerProps(prevProps, props); 24 | this._currentElement = nextComponent; 25 | this.node.invalidateLayout(); 26 | } 27 | 28 | }); 29 | 30 | module.exports = Layer; 31 | -------------------------------------------------------------------------------- /lib/LayerMixin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Adapted from ReactART: 4 | // https://github.com/reactjs/react-art 5 | 6 | var FrameUtils = require('./FrameUtils'); 7 | var DrawingUtils = require('./DrawingUtils'); 8 | var EventTypes = require('./EventTypes'); 9 | 10 | var LAYER_GUID = 0; 11 | 12 | var LayerMixin = { 13 | 14 | construct: function(element) { 15 | this._currentElement = element; 16 | this._layerId = LAYER_GUID++; 17 | }, 18 | 19 | getPublicInstance: function() { 20 | return this.node; 21 | }, 22 | 23 | putEventListener: function(type, listener) { 24 | var subscriptions = this.subscriptions || (this.subscriptions = {}); 25 | var listeners = this.listeners || (this.listeners = {}); 26 | listeners[type] = listener; 27 | if (listener) { 28 | if (!subscriptions[type]) { 29 | subscriptions[type] = this.node.subscribe(type, listener, this); 30 | } 31 | } else { 32 | if (subscriptions[type]) { 33 | subscriptions[type](); 34 | delete subscriptions[type]; 35 | } 36 | } 37 | }, 38 | 39 | handleEvent: function(event) { 40 | // TODO 41 | }, 42 | 43 | destroyEventListeners: function() { 44 | // TODO 45 | }, 46 | 47 | applyLayerProps: function (prevProps, props) { 48 | var layer = this.node; 49 | var style = (props && props.style) ? props.style : {}; 50 | layer._originalStyle = style; 51 | 52 | // Common layer properties 53 | layer.alpha = style.alpha; 54 | layer.backgroundColor = style.backgroundColor; 55 | layer.borderColor = style.borderColor; 56 | layer.borderWidth = style.borderWidth; 57 | layer.borderRadius = style.borderRadius; 58 | layer.clipRect = style.clipRect; 59 | layer.frame = FrameUtils.make(style.left || 0, style.top || 0, style.width || 0, style.height || 0); 60 | layer.scale = style.scale; 61 | layer.translateX = style.translateX; 62 | layer.translateY = style.translateY; 63 | layer.zIndex = style.zIndex; 64 | 65 | // Shadow 66 | layer.shadowColor = style.shadowColor; 67 | layer.shadowBlur = style.shadowBlur; 68 | layer.shadowOffsetX = style.shadowOffsetX; 69 | layer.shadowOffsetY = style.shadowOffsetY; 70 | 71 | // Generate backing store ID as needed. 72 | if (props.useBackingStore) { 73 | layer.backingStoreId = this._layerId; 74 | } 75 | 76 | // Register events 77 | for (var type in EventTypes) { 78 | this.putEventListener(EventTypes[type], props[type]); 79 | } 80 | }, 81 | 82 | mountComponentIntoNode: function(rootID, container) { 83 | throw new Error( 84 | 'You cannot render a Canvas component standalone. ' + 85 | 'You need to wrap it in a Surface.' 86 | ); 87 | }, 88 | 89 | unmountComponent: function() { 90 | this.destroyEventListeners(); 91 | }, 92 | getHostNode: function () { return this.node }, 93 | getNativeNode: function () { return this.node }, 94 | 95 | }; 96 | 97 | module.exports = LayerMixin; 98 | -------------------------------------------------------------------------------- /lib/Layout.js: -------------------------------------------------------------------------------- 1 | // https://github.com/facebook/css-layout 2 | 3 | /** 4 | * Copyright (c) 2014, Facebook, Inc. 5 | * All rights reserved. 6 | * 7 | * This source code is licensed under the BSD-style license found in the 8 | * LICENSE file in the root directory of this source tree. An additional grant 9 | * of patent rights can be found in the PATENTS file in the same directory. 10 | */ 11 | 12 | var computeLayout = (function() { 13 | 14 | function capitalizeFirst(str) { 15 | return str.charAt(0).toUpperCase() + str.slice(1); 16 | } 17 | 18 | function getSpacing(node, type, suffix, location) { 19 | var key = type + capitalizeFirst(location) + suffix; 20 | if (key in node.style) { 21 | return node.style[key]; 22 | } 23 | 24 | key = type + suffix; 25 | if (key in node.style) { 26 | return node.style[key]; 27 | } 28 | 29 | return 0; 30 | } 31 | 32 | function getPositiveSpacing(node, type, suffix, location) { 33 | var key = type + capitalizeFirst(location) + suffix; 34 | if (key in node.style && node.style[key] >= 0) { 35 | return node.style[key]; 36 | } 37 | 38 | key = type + suffix; 39 | if (key in node.style && node.style[key] >= 0) { 40 | return node.style[key]; 41 | } 42 | 43 | return 0; 44 | } 45 | 46 | function isUndefined(value) { 47 | return value === undefined; 48 | } 49 | 50 | function getMargin(node, location) { 51 | return getSpacing(node, 'margin', '', location); 52 | } 53 | 54 | function getPadding(node, location) { 55 | return getPositiveSpacing(node, 'padding', '', location); 56 | } 57 | 58 | function getBorder(node, location) { 59 | return getPositiveSpacing(node, 'border', 'Width', location); 60 | } 61 | 62 | function getPaddingAndBorder(node, location) { 63 | return getPadding(node, location) + getBorder(node, location); 64 | } 65 | 66 | function getMarginAxis(node, axis) { 67 | return getMargin(node, leading[axis]) + getMargin(node, trailing[axis]); 68 | } 69 | 70 | function getPaddingAndBorderAxis(node, axis) { 71 | return getPaddingAndBorder(node, leading[axis]) + getPaddingAndBorder(node, trailing[axis]); 72 | } 73 | 74 | function getJustifyContent(node) { 75 | if ('justifyContent' in node.style) { 76 | return node.style.justifyContent; 77 | } 78 | return 'flex-start'; 79 | } 80 | 81 | function getAlignItem(node, child) { 82 | if ('alignSelf' in child.style) { 83 | return child.style.alignSelf; 84 | } 85 | if ('alignItems' in node.style) { 86 | return node.style.alignItems; 87 | } 88 | return 'stretch'; 89 | } 90 | 91 | function getFlexDirection(node) { 92 | if ('flexDirection' in node.style) { 93 | return node.style.flexDirection; 94 | } 95 | return 'column'; 96 | } 97 | 98 | function getPositionType(node) { 99 | if ('position' in node.style) { 100 | return node.style.position; 101 | } 102 | return 'relative'; 103 | } 104 | 105 | function getFlex(node) { 106 | return node.style.flex; 107 | } 108 | 109 | function isFlex(node) { 110 | return ( 111 | getPositionType(node) === CSS_POSITION_RELATIVE && 112 | getFlex(node) > 0 113 | ); 114 | } 115 | 116 | function isFlexWrap(node) { 117 | return node.style.flexWrap === 'wrap'; 118 | } 119 | 120 | function getDimWithMargin(node, axis) { 121 | return node.layout[dim[axis]] + getMarginAxis(node, axis); 122 | } 123 | 124 | function isDimDefined(node, axis) { 125 | return !isUndefined(node.style[dim[axis]]) && node.style[dim[axis]] >= 0; 126 | } 127 | 128 | function isPosDefined(node, pos) { 129 | return !isUndefined(node.style[pos]); 130 | } 131 | 132 | function isMeasureDefined(node) { 133 | return 'measure' in node.style; 134 | } 135 | 136 | function getPosition(node, pos) { 137 | if (pos in node.style) { 138 | return node.style[pos]; 139 | } 140 | return 0; 141 | } 142 | 143 | // When the user specifically sets a value for width or height 144 | function setDimensionFromStyle(node, axis) { 145 | // The parent already computed us a width or height. We just skip it 146 | if (!isUndefined(node.layout[dim[axis]])) { 147 | return; 148 | } 149 | // We only run if there's a width or height defined 150 | if (!isDimDefined(node, axis)) { 151 | return; 152 | } 153 | 154 | // The dimensions can never be smaller than the padding and border 155 | node.layout[dim[axis]] = fmaxf( 156 | node.style[dim[axis]], 157 | getPaddingAndBorderAxis(node, axis) 158 | ); 159 | } 160 | 161 | // If both left and right are defined, then use left. Otherwise return 162 | // +left or -right depending on which is defined. 163 | function getRelativePosition(node, axis) { 164 | if (leading[axis] in node.style) { 165 | return getPosition(node, leading[axis]); 166 | } 167 | return -getPosition(node, trailing[axis]); 168 | } 169 | 170 | var leading = { 171 | row: 'left', 172 | column: 'top' 173 | }; 174 | var trailing = { 175 | row: 'right', 176 | column: 'bottom' 177 | }; 178 | var pos = { 179 | row: 'left', 180 | column: 'top' 181 | }; 182 | var dim = { 183 | row: 'width', 184 | column: 'height' 185 | }; 186 | 187 | function fmaxf(a, b) { 188 | if (a > b) { 189 | return a; 190 | } 191 | return b; 192 | } 193 | 194 | var CSS_UNDEFINED = undefined; 195 | 196 | var CSS_FLEX_DIRECTION_ROW = 'row'; 197 | var CSS_FLEX_DIRECTION_COLUMN = 'column'; 198 | 199 | var CSS_JUSTIFY_FLEX_START = 'flex-start'; 200 | var CSS_JUSTIFY_CENTER = 'center'; 201 | var CSS_JUSTIFY_FLEX_END = 'flex-end'; 202 | var CSS_JUSTIFY_SPACE_BETWEEN = 'space-between'; 203 | var CSS_JUSTIFY_SPACE_AROUND = 'space-around'; 204 | 205 | var CSS_ALIGN_FLEX_START = 'flex-start'; 206 | var CSS_ALIGN_CENTER = 'center'; 207 | var CSS_ALIGN_FLEX_END = 'flex-end'; 208 | var CSS_ALIGN_STRETCH = 'stretch'; 209 | 210 | var CSS_POSITION_RELATIVE = 'relative'; 211 | var CSS_POSITION_ABSOLUTE = 'absolute'; 212 | 213 | return function layoutNode(node, parentMaxWidth) { 214 | var/*css_flex_direction_t*/ mainAxis = getFlexDirection(node); 215 | var/*css_flex_direction_t*/ crossAxis = mainAxis === CSS_FLEX_DIRECTION_ROW ? 216 | CSS_FLEX_DIRECTION_COLUMN : 217 | CSS_FLEX_DIRECTION_ROW; 218 | 219 | // Handle width and height style attributes 220 | setDimensionFromStyle(node, mainAxis); 221 | setDimensionFromStyle(node, crossAxis); 222 | 223 | // The position is set by the parent, but we need to complete it with a 224 | // delta composed of the margin and left/top/right/bottom 225 | node.layout[leading[mainAxis]] += getMargin(node, leading[mainAxis]) + 226 | getRelativePosition(node, mainAxis); 227 | node.layout[leading[crossAxis]] += getMargin(node, leading[crossAxis]) + 228 | getRelativePosition(node, crossAxis); 229 | 230 | if (isMeasureDefined(node)) { 231 | var/*float*/ width = CSS_UNDEFINED; 232 | if (isDimDefined(node, CSS_FLEX_DIRECTION_ROW)) { 233 | width = node.style.width; 234 | } else if (!isUndefined(node.layout[dim[CSS_FLEX_DIRECTION_ROW]])) { 235 | width = node.layout[dim[CSS_FLEX_DIRECTION_ROW]]; 236 | } else { 237 | width = parentMaxWidth - 238 | getMarginAxis(node, CSS_FLEX_DIRECTION_ROW); 239 | } 240 | width -= getPaddingAndBorderAxis(node, CSS_FLEX_DIRECTION_ROW); 241 | 242 | // We only need to give a dimension for the text if we haven't got any 243 | // for it computed yet. It can either be from the style attribute or because 244 | // the element is flexible. 245 | var/*bool*/ isRowUndefined = !isDimDefined(node, CSS_FLEX_DIRECTION_ROW) && 246 | isUndefined(node.layout[dim[CSS_FLEX_DIRECTION_ROW]]); 247 | var/*bool*/ isColumnUndefined = !isDimDefined(node, CSS_FLEX_DIRECTION_COLUMN) && 248 | isUndefined(node.layout[dim[CSS_FLEX_DIRECTION_COLUMN]]); 249 | 250 | // Let's not measure the text if we already know both dimensions 251 | if (isRowUndefined || isColumnUndefined) { 252 | var/*css_dim_t*/ measure_dim = node.style.measure( 253 | /*(c)!node->context,*/ 254 | width 255 | ); 256 | if (isRowUndefined) { 257 | node.layout.width = measure_dim.width + 258 | getPaddingAndBorderAxis(node, CSS_FLEX_DIRECTION_ROW); 259 | } 260 | if (isColumnUndefined) { 261 | node.layout.height = measure_dim.height + 262 | getPaddingAndBorderAxis(node, CSS_FLEX_DIRECTION_COLUMN); 263 | } 264 | } 265 | return; 266 | } 267 | 268 | // Pre-fill some dimensions straight from the parent 269 | for (var/*int*/ i = 0; i < node.children.length; ++i) { 270 | var/*css_node_t**/ child = node.children[i]; 271 | // Pre-fill cross axis dimensions when the child is using stretch before 272 | // we call the recursive layout pass 273 | if (getAlignItem(node, child) === CSS_ALIGN_STRETCH && 274 | getPositionType(child) === CSS_POSITION_RELATIVE && 275 | !isUndefined(node.layout[dim[crossAxis]]) && 276 | !isDimDefined(child, crossAxis)) { 277 | child.layout[dim[crossAxis]] = fmaxf( 278 | node.layout[dim[crossAxis]] - 279 | getPaddingAndBorderAxis(node, crossAxis) - 280 | getMarginAxis(child, crossAxis), 281 | // You never want to go smaller than padding 282 | getPaddingAndBorderAxis(child, crossAxis) 283 | ); 284 | } else if (getPositionType(child) == CSS_POSITION_ABSOLUTE) { 285 | // Pre-fill dimensions when using absolute position and both offsets for the axis are defined (either both 286 | // left and right or top and bottom). 287 | for (var/*int*/ ii = 0; ii < 2; ii++) { 288 | var/*css_flex_direction_t*/ axis = (ii != 0) ? CSS_FLEX_DIRECTION_ROW : CSS_FLEX_DIRECTION_COLUMN; 289 | if (!isUndefined(node.layout[dim[axis]]) && 290 | !isDimDefined(child, axis) && 291 | isPosDefined(child, leading[axis]) && 292 | isPosDefined(child, trailing[axis])) { 293 | child.layout[dim[axis]] = fmaxf( 294 | node.layout[dim[axis]] - 295 | getPaddingAndBorderAxis(node, axis) - 296 | getMarginAxis(child, axis) - 297 | getPosition(child, leading[axis]) - 298 | getPosition(child, trailing[axis]), 299 | // You never want to go smaller than padding 300 | getPaddingAndBorderAxis(child, axis) 301 | ); 302 | } 303 | } 304 | } 305 | } 306 | 307 | var/*float*/ definedMainDim = CSS_UNDEFINED; 308 | if (!isUndefined(node.layout[dim[mainAxis]])) { 309 | definedMainDim = node.layout[dim[mainAxis]] - 310 | getPaddingAndBorderAxis(node, mainAxis); 311 | } 312 | 313 | // We want to execute the next two loops one per line with flex-wrap 314 | var/*int*/ startLine = 0; 315 | var/*int*/ endLine = 0; 316 | var/*int*/ nextOffset = 0; 317 | var/*int*/ alreadyComputedNextLayout = 0; 318 | // We aggregate the total dimensions of the container in those two variables 319 | var/*float*/ linesCrossDim = 0; 320 | var/*float*/ linesMainDim = 0; 321 | while (endLine < node.children.length) { 322 | // Layout non flexible children and count children by type 323 | 324 | // mainContentDim is accumulation of the dimensions and margin of all the 325 | // non flexible children. This will be used in order to either set the 326 | // dimensions of the node if none already exist, or to compute the 327 | // remaining space left for the flexible children. 328 | var/*float*/ mainContentDim = 0; 329 | 330 | // There are three kind of children, non flexible, flexible and absolute. 331 | // We need to know how many there are in order to distribute the space. 332 | var/*int*/ flexibleChildrenCount = 0; 333 | var/*float*/ totalFlexible = 0; 334 | var/*int*/ nonFlexibleChildrenCount = 0; 335 | for (var/*int*/ i = startLine; i < node.children.length; ++i) { 336 | var/*css_node_t**/ child = node.children[i]; 337 | var/*float*/ nextContentDim = 0; 338 | 339 | // It only makes sense to consider a child flexible if we have a computed 340 | // dimension for the node. 341 | if (!isUndefined(node.layout[dim[mainAxis]]) && isFlex(child)) { 342 | flexibleChildrenCount++; 343 | totalFlexible += getFlex(child); 344 | 345 | // Even if we don't know its exact size yet, we already know the padding, 346 | // border and margin. We'll use this partial information to compute the 347 | // remaining space. 348 | nextContentDim = getPaddingAndBorderAxis(child, mainAxis) + 349 | getMarginAxis(child, mainAxis); 350 | 351 | } else { 352 | var/*float*/ maxWidth = CSS_UNDEFINED; 353 | if (mainAxis === CSS_FLEX_DIRECTION_ROW) { 354 | // do nothing 355 | } else if (isDimDefined(node, CSS_FLEX_DIRECTION_ROW)) { 356 | maxWidth = node.layout[dim[CSS_FLEX_DIRECTION_ROW]] - 357 | getPaddingAndBorderAxis(node, CSS_FLEX_DIRECTION_ROW); 358 | } else { 359 | maxWidth = parentMaxWidth - 360 | getMarginAxis(node, CSS_FLEX_DIRECTION_ROW) - 361 | getPaddingAndBorderAxis(node, CSS_FLEX_DIRECTION_ROW); 362 | } 363 | 364 | // This is the main recursive call. We layout non flexible children. 365 | if (alreadyComputedNextLayout === 0) { 366 | layoutNode(child, maxWidth); 367 | } 368 | 369 | // Absolute positioned elements do not take part of the layout, so we 370 | // don't use them to compute mainContentDim 371 | if (getPositionType(child) === CSS_POSITION_RELATIVE) { 372 | nonFlexibleChildrenCount++; 373 | // At this point we know the final size and margin of the element. 374 | nextContentDim = getDimWithMargin(child, mainAxis); 375 | } 376 | } 377 | 378 | // The element we are about to add would make us go to the next line 379 | if (isFlexWrap(node) && 380 | !isUndefined(node.layout[dim[mainAxis]]) && 381 | mainContentDim + nextContentDim > definedMainDim && 382 | // If there's only one element, then it's bigger than the content 383 | // and needs its own line 384 | i !== startLine) { 385 | alreadyComputedNextLayout = 1; 386 | break; 387 | } 388 | alreadyComputedNextLayout = 0; 389 | mainContentDim += nextContentDim; 390 | endLine = i + 1; 391 | } 392 | 393 | // Layout flexible children and allocate empty space 394 | 395 | // In order to position the elements in the main axis, we have two 396 | // controls. The space between the beginning and the first element 397 | // and the space between each two elements. 398 | var/*float*/ leadingMainDim = 0; 399 | var/*float*/ betweenMainDim = 0; 400 | 401 | // The remaining available space that needs to be allocated 402 | var/*float*/ remainingMainDim = 0; 403 | if (!isUndefined(node.layout[dim[mainAxis]])) { 404 | remainingMainDim = definedMainDim - mainContentDim; 405 | } else { 406 | remainingMainDim = fmaxf(mainContentDim, 0) - mainContentDim; 407 | } 408 | 409 | // If there are flexible children in the mix, they are going to fill the 410 | // remaining space 411 | if (flexibleChildrenCount !== 0) { 412 | var/*float*/ flexibleMainDim = remainingMainDim / totalFlexible; 413 | 414 | // The non flexible children can overflow the container, in this case 415 | // we should just assume that there is no space available. 416 | if (flexibleMainDim < 0) { 417 | flexibleMainDim = 0; 418 | } 419 | // We iterate over the full array and only apply the action on flexible 420 | // children. This is faster than actually allocating a new array that 421 | // contains only flexible children. 422 | for (var/*int*/ i = startLine; i < endLine; ++i) { 423 | var/*css_node_t**/ child = node.children[i]; 424 | if (isFlex(child)) { 425 | // At this point we know the final size of the element in the main 426 | // dimension 427 | child.layout[dim[mainAxis]] = flexibleMainDim * getFlex(child) + 428 | getPaddingAndBorderAxis(child, mainAxis); 429 | 430 | var/*float*/ maxWidth = CSS_UNDEFINED; 431 | if (mainAxis === CSS_FLEX_DIRECTION_ROW) { 432 | // do nothing 433 | } else if (isDimDefined(node, CSS_FLEX_DIRECTION_ROW)) { 434 | maxWidth = node.layout[dim[CSS_FLEX_DIRECTION_ROW]] - 435 | getPaddingAndBorderAxis(node, CSS_FLEX_DIRECTION_ROW); 436 | } else { 437 | maxWidth = parentMaxWidth - 438 | getMarginAxis(node, CSS_FLEX_DIRECTION_ROW) - 439 | getPaddingAndBorderAxis(node, CSS_FLEX_DIRECTION_ROW); 440 | } 441 | 442 | // And we recursively call the layout algorithm for this child 443 | layoutNode(child, maxWidth); 444 | } 445 | } 446 | 447 | // We use justifyContent to figure out how to allocate the remaining 448 | // space available 449 | } else { 450 | var/*css_justify_t*/ justifyContent = getJustifyContent(node); 451 | if (justifyContent === CSS_JUSTIFY_FLEX_START) { 452 | // Do nothing 453 | } else if (justifyContent === CSS_JUSTIFY_CENTER) { 454 | leadingMainDim = remainingMainDim / 2; 455 | } else if (justifyContent === CSS_JUSTIFY_FLEX_END) { 456 | leadingMainDim = remainingMainDim; 457 | } else if (justifyContent === CSS_JUSTIFY_SPACE_BETWEEN) { 458 | remainingMainDim = fmaxf(remainingMainDim, 0); 459 | if (flexibleChildrenCount + nonFlexibleChildrenCount - 1 !== 0) { 460 | betweenMainDim = remainingMainDim / 461 | (flexibleChildrenCount + nonFlexibleChildrenCount - 1); 462 | } else { 463 | betweenMainDim = 0; 464 | } 465 | } else if (justifyContent === CSS_JUSTIFY_SPACE_AROUND) { 466 | // Space on the edges is half of the space between elements 467 | betweenMainDim = remainingMainDim / 468 | (flexibleChildrenCount + nonFlexibleChildrenCount); 469 | leadingMainDim = betweenMainDim / 2; 470 | } 471 | } 472 | 473 | // Position elements in the main axis and compute dimensions 474 | 475 | // At this point, all the children have their dimensions set. We need to 476 | // find their position. In order to do that, we accumulate data in 477 | // variables that are also useful to compute the total dimensions of the 478 | // container! 479 | var/*float*/ crossDim = 0; 480 | var/*float*/ mainDim = leadingMainDim + 481 | getPaddingAndBorder(node, leading[mainAxis]); 482 | 483 | for (var/*int*/ i = startLine; i < endLine; ++i) { 484 | var/*css_node_t**/ child = node.children[i]; 485 | 486 | if (getPositionType(child) === CSS_POSITION_ABSOLUTE && 487 | isPosDefined(child, leading[mainAxis])) { 488 | // In case the child is position absolute and has left/top being 489 | // defined, we override the position to whatever the user said 490 | // (and margin/border). 491 | child.layout[pos[mainAxis]] = getPosition(child, leading[mainAxis]) + 492 | getBorder(node, leading[mainAxis]) + 493 | getMargin(child, leading[mainAxis]); 494 | } else { 495 | // If the child is position absolute (without top/left) or relative, 496 | // we put it at the current accumulated offset. 497 | child.layout[pos[mainAxis]] += mainDim; 498 | } 499 | 500 | // Now that we placed the element, we need to update the variables 501 | // We only need to do that for relative elements. Absolute elements 502 | // do not take part in that phase. 503 | if (getPositionType(child) === CSS_POSITION_RELATIVE) { 504 | // The main dimension is the sum of all the elements dimension plus 505 | // the spacing. 506 | mainDim += betweenMainDim + getDimWithMargin(child, mainAxis); 507 | // The cross dimension is the max of the elements dimension since there 508 | // can only be one element in that cross dimension. 509 | crossDim = fmaxf(crossDim, getDimWithMargin(child, crossAxis)); 510 | } 511 | } 512 | 513 | var/*float*/ containerMainAxis = node.layout[dim[mainAxis]]; 514 | // If the user didn't specify a width or height, and it has not been set 515 | // by the container, then we set it via the children. 516 | if (isUndefined(node.layout[dim[mainAxis]])) { 517 | containerMainAxis = fmaxf( 518 | // We're missing the last padding at this point to get the final 519 | // dimension 520 | mainDim + getPaddingAndBorder(node, trailing[mainAxis]), 521 | // We can never assign a width smaller than the padding and borders 522 | getPaddingAndBorderAxis(node, mainAxis) 523 | ); 524 | } 525 | 526 | var/*float*/ containerCrossAxis = node.layout[dim[crossAxis]]; 527 | if (isUndefined(node.layout[dim[crossAxis]])) { 528 | containerCrossAxis = fmaxf( 529 | // For the cross dim, we add both sides at the end because the value 530 | // is aggregate via a max function. Intermediate negative values 531 | // can mess this computation otherwise 532 | crossDim + getPaddingAndBorderAxis(node, crossAxis), 533 | getPaddingAndBorderAxis(node, crossAxis) 534 | ); 535 | } 536 | 537 | // Position elements in the cross axis 538 | 539 | for (var/*int*/ i = startLine; i < endLine; ++i) { 540 | var/*css_node_t**/ child = node.children[i]; 541 | 542 | if (getPositionType(child) === CSS_POSITION_ABSOLUTE && 543 | isPosDefined(child, leading[crossAxis])) { 544 | // In case the child is absolutely positionned and has a 545 | // top/left/bottom/right being set, we override all the previously 546 | // computed positions to set it correctly. 547 | child.layout[pos[crossAxis]] = getPosition(child, leading[crossAxis]) + 548 | getBorder(node, leading[crossAxis]) + 549 | getMargin(child, leading[crossAxis]); 550 | 551 | } else { 552 | var/*float*/ leadingCrossDim = getPaddingAndBorder(node, leading[crossAxis]); 553 | 554 | // For a relative children, we're either using alignItems (parent) or 555 | // alignSelf (child) in order to determine the position in the cross axis 556 | if (getPositionType(child) === CSS_POSITION_RELATIVE) { 557 | var/*css_align_t*/ alignItem = getAlignItem(node, child); 558 | if (alignItem === CSS_ALIGN_FLEX_START) { 559 | // Do nothing 560 | } else if (alignItem === CSS_ALIGN_STRETCH) { 561 | // You can only stretch if the dimension has not already been set 562 | // previously. 563 | if (!isDimDefined(child, crossAxis)) { 564 | child.layout[dim[crossAxis]] = fmaxf( 565 | containerCrossAxis - 566 | getPaddingAndBorderAxis(node, crossAxis) - 567 | getMarginAxis(child, crossAxis), 568 | // You never want to go smaller than padding 569 | getPaddingAndBorderAxis(child, crossAxis) 570 | ); 571 | } 572 | } else { 573 | // The remaining space between the parent dimensions+padding and child 574 | // dimensions+margin. 575 | var/*float*/ remainingCrossDim = containerCrossAxis - 576 | getPaddingAndBorderAxis(node, crossAxis) - 577 | getDimWithMargin(child, crossAxis); 578 | 579 | if (alignItem === CSS_ALIGN_CENTER) { 580 | leadingCrossDim += remainingCrossDim / 2; 581 | } else { // CSS_ALIGN_FLEX_END 582 | leadingCrossDim += remainingCrossDim; 583 | } 584 | } 585 | } 586 | 587 | // And we apply the position 588 | child.layout[pos[crossAxis]] += linesCrossDim + leadingCrossDim; 589 | } 590 | } 591 | 592 | linesCrossDim += crossDim; 593 | linesMainDim = fmaxf(linesMainDim, mainDim); 594 | startLine = endLine; 595 | } 596 | 597 | // If the user didn't specify a width or height, and it has not been set 598 | // by the container, then we set it via the children. 599 | if (isUndefined(node.layout[dim[mainAxis]])) { 600 | node.layout[dim[mainAxis]] = fmaxf( 601 | // We're missing the last padding at this point to get the final 602 | // dimension 603 | linesMainDim + getPaddingAndBorder(node, trailing[mainAxis]), 604 | // We can never assign a width smaller than the padding and borders 605 | getPaddingAndBorderAxis(node, mainAxis) 606 | ); 607 | } 608 | 609 | if (isUndefined(node.layout[dim[crossAxis]])) { 610 | node.layout[dim[crossAxis]] = fmaxf( 611 | // For the cross dim, we add both sides at the end because the value 612 | // is aggregate via a max function. Intermediate negative values 613 | // can mess this computation otherwise 614 | linesCrossDim + getPaddingAndBorderAxis(node, crossAxis), 615 | getPaddingAndBorderAxis(node, crossAxis) 616 | ); 617 | } 618 | 619 | // Calculate dimensions for absolutely positioned elements 620 | 621 | for (var/*int*/ i = 0; i < node.children.length; ++i) { 622 | var/*css_node_t**/ child = node.children[i]; 623 | if (getPositionType(child) == CSS_POSITION_ABSOLUTE) { 624 | // Pre-fill dimensions when using absolute position and both offsets for the axis are defined (either both 625 | // left and right or top and bottom). 626 | for (var/*int*/ ii = 0; ii < 2; ii++) { 627 | var/*css_flex_direction_t*/ axis = (ii !== 0) ? CSS_FLEX_DIRECTION_ROW : CSS_FLEX_DIRECTION_COLUMN; 628 | if (!isUndefined(node.layout[dim[axis]]) && 629 | !isDimDefined(child, axis) && 630 | isPosDefined(child, leading[axis]) && 631 | isPosDefined(child, trailing[axis])) { 632 | child.layout[dim[axis]] = fmaxf( 633 | node.layout[dim[axis]] - 634 | getPaddingAndBorderAxis(node, axis) - 635 | getMarginAxis(child, axis) - 636 | getPosition(child, leading[axis]) - 637 | getPosition(child, trailing[axis]), 638 | // You never want to go smaller than padding 639 | getPaddingAndBorderAxis(child, axis) 640 | ); 641 | } 642 | } 643 | for (var/*int*/ ii = 0; ii < 2; ii++) { 644 | var/*css_flex_direction_t*/ axis = (ii !== 0) ? CSS_FLEX_DIRECTION_ROW : CSS_FLEX_DIRECTION_COLUMN; 645 | if (isPosDefined(child, trailing[axis]) && 646 | !isPosDefined(child, leading[axis])) { 647 | child.layout[leading[axis]] = 648 | node.layout[dim[axis]] - 649 | child.layout[dim[axis]] - 650 | getPosition(child, trailing[axis]); 651 | } 652 | } 653 | } 654 | } 655 | }; 656 | })(); 657 | 658 | if (typeof module === 'object') { 659 | module.exports = computeLayout; 660 | } 661 | -------------------------------------------------------------------------------- /lib/ListView.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react'); 4 | var Scroller = require('scroller'); 5 | var Group = require('./Group'); 6 | var clamp = require('./clamp'); 7 | 8 | var ListView = React.createClass({ 9 | 10 | propTypes: { 11 | style: React.PropTypes.object, 12 | numberOfItemsGetter: React.PropTypes.func.isRequired, 13 | itemHeightGetter: React.PropTypes.func.isRequired, 14 | itemGetter: React.PropTypes.func.isRequired, 15 | snapping: React.PropTypes.bool, 16 | scrollingDeceleration: React.PropTypes.number, 17 | scrollingPenetrationAcceleration: React.PropTypes.number, 18 | onScroll: React.PropTypes.func 19 | }, 20 | 21 | getDefaultProps: function () { 22 | return { 23 | style: { left: 0, top: 0, width: 0, height: 0 }, 24 | snapping: false, 25 | scrollingDeceleration: 0.95, 26 | scrollingPenetrationAcceleration: 0.08 27 | }; 28 | }, 29 | 30 | getInitialState: function () { 31 | return { 32 | scrollTop: 0 33 | }; 34 | }, 35 | 36 | componentDidMount: function () { 37 | this.createScroller(); 38 | this.updateScrollingDimensions(); 39 | }, 40 | 41 | render: function () { 42 | var items = this.getVisibleItemIndexes().map(this.renderItem); 43 | return ( 44 | React.createElement(Group, { 45 | style: this.props.style, 46 | onTouchStart: this.handleTouchStart, 47 | onTouchMove: this.handleTouchMove, 48 | onTouchEnd: this.handleTouchEnd, 49 | onTouchCancel: this.handleTouchEnd}, 50 | items 51 | ) 52 | ); 53 | }, 54 | 55 | renderItem: function (itemIndex) { 56 | var item = this.props.itemGetter(itemIndex, this.state.scrollTop); 57 | var itemHeight = this.props.itemHeightGetter(); 58 | var style = { 59 | top: 0, 60 | left: 0, 61 | width: this.props.style.width, 62 | height: itemHeight, 63 | translateY: (itemIndex * itemHeight) - this.state.scrollTop, 64 | zIndex: itemIndex 65 | }; 66 | 67 | return ( 68 | React.createElement(Group, {style: style, key: itemIndex}, 69 | item 70 | ) 71 | ); 72 | }, 73 | 74 | // Events 75 | // ====== 76 | 77 | handleTouchStart: function (e) { 78 | if (this.scroller) { 79 | this.scroller.doTouchStart(e.touches, e.timeStamp); 80 | } 81 | }, 82 | 83 | handleTouchMove: function (e) { 84 | if (this.scroller) { 85 | e.preventDefault(); 86 | this.scroller.doTouchMove(e.touches, e.timeStamp, e.scale); 87 | } 88 | }, 89 | 90 | handleTouchEnd: function (e) { 91 | if (this.scroller) { 92 | this.scroller.doTouchEnd(e.timeStamp); 93 | if (this.props.snapping) { 94 | this.updateScrollingDeceleration(); 95 | } 96 | } 97 | }, 98 | 99 | handleScroll: function (left, top) { 100 | this.setState({ scrollTop: top }); 101 | if (this.props.onScroll) { 102 | this.props.onScroll(top); 103 | } 104 | }, 105 | 106 | // Scrolling 107 | // ========= 108 | 109 | createScroller: function () { 110 | var options = { 111 | scrollingX: false, 112 | scrollingY: true, 113 | decelerationRate: this.props.scrollingDeceleration, 114 | penetrationAcceleration: this.props.scrollingPenetrationAcceleration, 115 | }; 116 | this.scroller = new Scroller(this.handleScroll, options); 117 | }, 118 | 119 | updateScrollingDimensions: function () { 120 | var width = this.props.style.width; 121 | var height = this.props.style.height; 122 | var scrollWidth = width; 123 | var scrollHeight = this.props.numberOfItemsGetter() * this.props.itemHeightGetter(); 124 | this.scroller.setDimensions(width, height, scrollWidth, scrollHeight); 125 | }, 126 | 127 | getVisibleItemIndexes: function () { 128 | var itemIndexes = []; 129 | var itemHeight = this.props.itemHeightGetter(); 130 | var itemCount = this.props.numberOfItemsGetter(); 131 | var scrollTop = this.state.scrollTop; 132 | var itemScrollTop = 0; 133 | 134 | for (var index=0; index < itemCount; index++) { 135 | itemScrollTop = (index * itemHeight) - scrollTop; 136 | 137 | // Item is completely off-screen bottom 138 | if (itemScrollTop >= this.props.style.height) { 139 | continue; 140 | } 141 | 142 | // Item is completely off-screen top 143 | if (itemScrollTop <= -this.props.style.height) { 144 | continue; 145 | } 146 | 147 | // Part of item is on-screen. 148 | itemIndexes.push(index); 149 | } 150 | 151 | return itemIndexes; 152 | }, 153 | 154 | updateScrollingDeceleration: function () { 155 | var currVelocity = this.scroller.__decelerationVelocityY; 156 | var currScrollTop = this.state.scrollTop; 157 | var targetScrollTop = 0; 158 | var estimatedEndScrollTop = currScrollTop; 159 | 160 | while (Math.abs(currVelocity).toFixed(6) > 0) { 161 | estimatedEndScrollTop += currVelocity; 162 | currVelocity *= this.props.scrollingDeceleration; 163 | } 164 | 165 | // Find the page whose estimated end scrollTop is closest to 0. 166 | var closestZeroDelta = Infinity; 167 | var pageHeight = this.props.itemHeightGetter(); 168 | var pageCount = this.props.numberOfItemsGetter(); 169 | var pageScrollTop; 170 | 171 | for (var pageIndex=0, len=pageCount; pageIndex < len; pageIndex++) { 172 | pageScrollTop = (pageHeight * pageIndex) - estimatedEndScrollTop; 173 | if (Math.abs(pageScrollTop) < closestZeroDelta) { 174 | closestZeroDelta = Math.abs(pageScrollTop); 175 | targetScrollTop = pageHeight * pageIndex; 176 | } 177 | } 178 | 179 | this.scroller.__minDecelerationScrollTop = targetScrollTop; 180 | this.scroller.__maxDecelerationScrollTop = targetScrollTop; 181 | } 182 | 183 | }); 184 | 185 | module.exports = ListView; 186 | -------------------------------------------------------------------------------- /lib/ReactCanvas.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var ReactCanvas = { 4 | Surface: require('./Surface'), 5 | 6 | Layer: require('./Layer'), 7 | Group: require('./Group'), 8 | Image: require('./Image'), 9 | Text: require('./Text'), 10 | ListView: require('./ListView'), 11 | Gradient: require('./Gradient'), 12 | 13 | FontFace: require('./FontFace'), 14 | measureText: require('./measureText') 15 | }; 16 | 17 | module.exports = ReactCanvas; 18 | -------------------------------------------------------------------------------- /lib/RenderLayer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var FrameUtils = require('./FrameUtils'); 4 | var DrawingUtils = require('./DrawingUtils'); 5 | var EventTypes = require('./EventTypes'); 6 | 7 | function RenderLayer () { 8 | this.children = []; 9 | this.frame = FrameUtils.zero(); 10 | } 11 | 12 | RenderLayer.prototype = { 13 | 14 | /** 15 | * Retrieve the root injection layer 16 | * 17 | * @return {RenderLayer} 18 | */ 19 | getRootLayer: function () { 20 | var root = this; 21 | while (root.parentLayer) { 22 | root = root.parentLayer; 23 | } 24 | return root; 25 | }, 26 | 27 | /** 28 | * RenderLayers are injected into a root owner layer whenever a Surface is 29 | * mounted. This is the integration point with React internals. 30 | * 31 | * @param {RenderLayer} parentLayer 32 | */ 33 | inject: function (parentLayer) { 34 | if (this.parentLayer && this.parentLayer !== parentLayer) { 35 | this.remove(); 36 | } 37 | if (!this.parentLayer) { 38 | parentLayer.addChild(this); 39 | } 40 | }, 41 | 42 | /** 43 | * Inject a layer before a reference layer 44 | * 45 | * @param {RenderLayer} parentLayer 46 | * @param {RenderLayer} referenceLayer 47 | */ 48 | injectBefore: function (parentLayer, referenceLayer) { 49 | // FIXME 50 | this.inject(parentLayer); 51 | }, 52 | 53 | /** 54 | * Add a child to the render layer 55 | * 56 | * @param {RenderLayer} child 57 | */ 58 | addChild: function (child) { 59 | child.parentLayer = this; 60 | this.children.push(child); 61 | }, 62 | 63 | /** 64 | * Remove a layer from it's parent layer 65 | */ 66 | remove: function () { 67 | if (this.parentLayer) { 68 | this.parentLayer.children.splice(this.parentLayer.children.indexOf(this), 1); 69 | } 70 | }, 71 | 72 | /** 73 | * Attach an event listener to a layer. Supported events are defined in 74 | * lib/EventTypes.js 75 | * 76 | * @param {String} type 77 | * @param {Function} callback 78 | * @param {?Object} callbackScope 79 | * @return {Function} invoke to unsubscribe the listener 80 | */ 81 | subscribe: function (type, callback, callbackScope) { 82 | // This is the integration point with React, called from LayerMixin.putEventListener(). 83 | // Enforce that only a single callbcak can be assigned per event type. 84 | for (var eventType in EventTypes) { 85 | if (EventTypes[eventType] === type) { 86 | this[eventType] = callback; 87 | } 88 | } 89 | 90 | // Return a function that can be called to unsubscribe from the event. 91 | return this.removeEventListener.bind(this, type, callback, callbackScope); 92 | }, 93 | 94 | /** 95 | * @param {String} type 96 | * @param {Function} callback 97 | * @param {?Object} callbackScope 98 | */ 99 | addEventListener: function (type, callback, callbackScope) { 100 | for (var eventType in EventTypes) { 101 | if (EventTypes[eventType] === type) { 102 | delete this[eventType]; 103 | } 104 | } 105 | }, 106 | 107 | /** 108 | * @param {String} type 109 | * @param {Function} callback 110 | * @param {?Object} callbackScope 111 | */ 112 | removeEventListener: function (type, callback, callbackScope) { 113 | var listeners = this.eventListeners[type]; 114 | var listener; 115 | if (listeners) { 116 | for (var index=0, len=listeners.length; index < len; index++) { 117 | listener = listeners[index]; 118 | if (listener.callback === callback && 119 | listener.callbackScope === callbackScope) { 120 | listeners.splice(index, 1); 121 | break; 122 | } 123 | } 124 | } 125 | }, 126 | 127 | /** 128 | * Translate a layer's frame 129 | * 130 | * @param {Number} x 131 | * @param {Number} y 132 | */ 133 | translate: function (x, y) { 134 | if (this.frame) { 135 | this.frame.x += x; 136 | this.frame.y += y; 137 | } 138 | 139 | if (this.clipRect) { 140 | this.clipRect.x += x; 141 | this.clipRect.y += y; 142 | } 143 | 144 | if (this.children) { 145 | this.children.forEach(function (child) { 146 | child.translate(x, y); 147 | }); 148 | } 149 | }, 150 | 151 | /** 152 | * Layers should call this method when they need to be redrawn. Note the 153 | * difference here between `invalidateBackingStore`: updates that don't 154 | * trigger layout should prefer `invalidateLayout`. For instance, an image 155 | * component that is animating alpha level after the image loads would 156 | * call `invalidateBackingStore` once after the image loads, and at each 157 | * step in the animation would then call `invalidateRect`. 158 | * 159 | * @param {?Frame} frame Optional, if not passed the entire layer's frame 160 | * will be invalidated. 161 | */ 162 | invalidateLayout: function () { 163 | // Bubble all the way to the root layer. 164 | this.getRootLayer().draw(); 165 | }, 166 | 167 | /** 168 | * Layers should call this method when their backing needs to be 169 | * redrawn. For instance, an image component would call this once after the 170 | * image loads. 171 | */ 172 | invalidateBackingStore: function () { 173 | if (this.backingStoreId) { 174 | DrawingUtils.invalidateBackingStore(this.backingStoreId); 175 | } 176 | this.invalidateLayout(); 177 | }, 178 | 179 | /** 180 | * Only the root owning layer should implement this function. 181 | */ 182 | draw: function () { 183 | // Placeholer 184 | } 185 | 186 | }; 187 | 188 | module.exports = RenderLayer; 189 | -------------------------------------------------------------------------------- /lib/Surface.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var React = require('react'); 4 | var ReactUpdates = require('react-dom/lib/ReactUpdates'); 5 | var invariant = require('fbjs/lib/invariant'); 6 | var ContainerMixin = require('./ContainerMixin'); 7 | var RenderLayer = require('./RenderLayer'); 8 | var FrameUtils = require('./FrameUtils'); 9 | var DrawingUtils = require('./DrawingUtils'); 10 | var hitTest = require('./hitTest'); 11 | var layoutNode = require('./layoutNode'); 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 | 18 | var Surface = React.createClass({ 19 | 20 | mixins: [ContainerMixin], 21 | 22 | propTypes: { 23 | className: React.PropTypes.string, 24 | id: React.PropTypes.string, 25 | top: React.PropTypes.number.isRequired, 26 | left: React.PropTypes.number.isRequired, 27 | width: React.PropTypes.number.isRequired, 28 | height: React.PropTypes.number.isRequired, 29 | scale: React.PropTypes.number.isRequired, 30 | enableCSSLayout: React.PropTypes.bool 31 | }, 32 | 33 | getDefaultProps: function () { 34 | return { 35 | scale: window.devicePixelRatio || 1 36 | }; 37 | }, 38 | 39 | componentDidMount: function () { 40 | // Prepare the for drawing. 41 | this.scale(); 42 | 43 | // ContainerMixin expects `this.node` to be set prior to mounting children. 44 | // `this.node` is injected into child components and represents the current 45 | // render tree. 46 | this.node = new RenderLayer(); 47 | this.node.frame = FrameUtils.make(this.props.left, this.props.top, this.props.width, this.props.height); 48 | this.node.draw = this.batchedTick; 49 | 50 | // This is the integration point between custom canvas components and React 51 | var transaction = ReactUpdates.ReactReconcileTransaction.getPooled(); 52 | transaction.perform( 53 | this.mountAndInjectChildrenAtRoot, 54 | this, 55 | this.props.children, 56 | transaction 57 | ); 58 | ReactUpdates.ReactReconcileTransaction.release(transaction); 59 | 60 | // Execute initial draw on mount. 61 | this.node.draw(); 62 | }, 63 | 64 | componentWillUnmount: function () { 65 | // Implemented in ReactMultiChild.Mixin 66 | this.unmountChildren(); 67 | }, 68 | 69 | componentDidUpdate: function (prevProps, prevState) { 70 | // We have to manually apply child reconciliation since child are not 71 | // declared in render(). 72 | var transaction = ReactUpdates.ReactReconcileTransaction.getPooled(); 73 | transaction.perform( 74 | this.updateChildrenAtRoot, 75 | this, 76 | this.props.children, 77 | transaction 78 | ); 79 | ReactUpdates.ReactReconcileTransaction.release(transaction); 80 | 81 | // Re-scale the when changing size. 82 | if (prevProps.width !== this.props.width || prevProps.height !== this.props.height) { 83 | this.scale(); 84 | } 85 | 86 | // Redraw updated render tree to . 87 | if (this.node) { 88 | this.node.draw(); 89 | } 90 | }, 91 | 92 | render: function () { 93 | // Scale the drawing area to match DPI. 94 | var width = this.props.width * this.props.scale; 95 | var height = this.props.height * this.props.scale; 96 | var style = { 97 | width: this.props.width, 98 | height: this.props.height 99 | }; 100 | 101 | return ( 102 | React.createElement('canvas', { 103 | ref: 'canvas', 104 | className: this.props.className, 105 | id: this.props.id, 106 | width: width, 107 | height: height, 108 | style: style, 109 | onTouchStart: this.handleTouchStart, 110 | onTouchMove: this.handleTouchMove, 111 | onTouchEnd: this.handleTouchEnd, 112 | onTouchCancel: this.handleTouchEnd, 113 | onClick: this.handleClick, 114 | onContextMenu: this.handleContextMenu, 115 | onDoubleClick: this.handleDoubleClick}) 116 | ); 117 | }, 118 | 119 | // Drawing 120 | // ======= 121 | 122 | getContext: function () { 123 | ('production' !== process.env.NODE_ENV ? invariant( 124 | this.isMounted(), 125 | 'Tried to access drawing context on an unmounted Surface.' 126 | ) : invariant(this.isMounted())); 127 | return this.refs.canvas.getContext('2d'); 128 | }, 129 | 130 | scale: function () { 131 | this.getContext().scale(this.props.scale, this.props.scale); 132 | }, 133 | 134 | batchedTick: function () { 135 | if (this._frameReady === false) { 136 | this._pendingTick = true; 137 | return; 138 | } 139 | this.tick(); 140 | }, 141 | 142 | tick: function () { 143 | // Block updates until next animation frame. 144 | this._frameReady = false; 145 | this.clear(); 146 | this.draw(); 147 | requestAnimationFrame(this.afterTick); 148 | }, 149 | 150 | afterTick: function () { 151 | // Execute pending draw that may have been scheduled during previous frame 152 | this._frameReady = true; 153 | if (this._pendingTick) { 154 | this._pendingTick = false; 155 | this.batchedTick(); 156 | } 157 | }, 158 | 159 | clear: function () { 160 | this.getContext().clearRect(0, 0, this.props.width, this.props.height); 161 | }, 162 | 163 | draw: function () { 164 | var layout; 165 | if (this.node) { 166 | if (this.props.enableCSSLayout) { 167 | layout = layoutNode(this.node); 168 | } 169 | DrawingUtils.drawRenderLayer(this.getContext(), this.node); 170 | } 171 | }, 172 | 173 | // Events 174 | // ====== 175 | 176 | hitTest: function (e) { 177 | var hitTarget = hitTest(e, this.node, this.refs.canvas); 178 | if (hitTarget) { 179 | hitTarget[hitTest.getHitHandle(e.type)](e); 180 | } 181 | }, 182 | 183 | handleTouchStart: function (e) { 184 | var hitTarget = hitTest(e, this.node, this.refs.canvas); 185 | var touch; 186 | if (hitTarget) { 187 | // On touchstart: capture the current hit target for the given touch. 188 | this._touches = this._touches || {}; 189 | for (var i=0, len=e.touches.length; i < len; i++) { 190 | touch = e.touches[i]; 191 | this._touches[touch.identifier] = hitTarget; 192 | } 193 | hitTarget[hitTest.getHitHandle(e.type)](e); 194 | } 195 | }, 196 | 197 | handleTouchMove: function (e) { 198 | this.hitTest(e); 199 | }, 200 | 201 | handleTouchEnd: function (e) { 202 | // touchend events do not generate a pageX/pageY so we rely 203 | // on the currently captured touch targets. 204 | if (!this._touches) { 205 | return; 206 | } 207 | 208 | var hitTarget; 209 | var hitHandle = hitTest.getHitHandle(e.type); 210 | for (var i=0, len=e.changedTouches.length; i < len; i++) { 211 | hitTarget = this._touches[e.changedTouches[i].identifier]; 212 | if (hitTarget && hitTarget[hitHandle]) { 213 | hitTarget[hitHandle](e); 214 | } 215 | delete this._touches[e.changedTouches[i].identifier]; 216 | } 217 | }, 218 | 219 | handleClick: function (e) { 220 | this.hitTest(e); 221 | }, 222 | 223 | handleContextMenu: function (e) { 224 | this.hitTest(e); 225 | }, 226 | 227 | handleDoubleClick: function (e) { 228 | this.hitTest(e); 229 | }, 230 | 231 | }); 232 | 233 | module.exports = Surface; 234 | -------------------------------------------------------------------------------- /lib/Text.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var createComponent = require('./createComponent'); 4 | var LayerMixin = require('./LayerMixin'); 5 | 6 | var Text = createComponent('Text', LayerMixin, { 7 | 8 | applyTextProps: function (prevProps, props) { 9 | var style = (props && props.style) ? props.style : {}; 10 | var layer = this.node; 11 | 12 | layer.type = 'text'; 13 | layer.text = childrenAsString(props.children); 14 | 15 | layer.color = style.color; 16 | layer.fontFace = style.fontFace; 17 | layer.fontSize = style.fontSize; 18 | layer.lineHeight = style.lineHeight; 19 | layer.textAlign = style.textAlign; 20 | }, 21 | 22 | mountComponent: function ( 23 | transaction, 24 | nativeParent, 25 | nativeContainerInfo, 26 | context 27 | ) { 28 | var props = this._currentElement.props; 29 | var layer = this.node; 30 | this.applyLayerProps({}, props); 31 | this.applyTextProps({}, props); 32 | return layer; 33 | }, 34 | 35 | receiveComponent: function (nextComponent, transaction, context) { 36 | var props = nextComponent.props; 37 | var prevProps = this._currentElement.props; 38 | this.applyLayerProps(prevProps, props); 39 | this.applyTextProps(prevProps, props); 40 | this._currentElement = nextComponent; 41 | this.node.invalidateLayout(); 42 | } 43 | 44 | }); 45 | 46 | function childrenAsString(children) { 47 | if (!children) { 48 | return ''; 49 | } 50 | if (typeof children === 'string') { 51 | return children; 52 | } 53 | if (children.length) { 54 | return children.join('\n'); 55 | } 56 | return ''; 57 | } 58 | 59 | module.exports = Text; -------------------------------------------------------------------------------- /lib/__tests__/clamp-test.js: -------------------------------------------------------------------------------- 1 | jest.dontMock('../clamp.js'); 2 | 3 | var clamp = require('../clamp'); 4 | 5 | describe('clamp', function() { 6 | it('returns the min if n is less than min', function() { 7 | expect(clamp(-1, 0, 1)).toBe(0); 8 | }); 9 | 10 | it('returns the max if n is greater than max', function() { 11 | expect(clamp(2, 0, 1)).toBe(1); 12 | }); 13 | 14 | it('returns n if n is between min and max', function() { 15 | expect(clamp(0.5, 0, 1)).toBe(0.5); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /lib/clamp.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Clamp a number between a minimum and maximum value. 5 | * @param {Number} number 6 | * @param {Number} min 7 | * @param {Number} max 8 | * @return {Number} 9 | */ 10 | module.exports = function (number, min, max) { 11 | return Math.min(Math.max(number, min), max); 12 | }; 13 | -------------------------------------------------------------------------------- /lib/createComponent.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Adapted from ReactART: 4 | // https://github.com/reactjs/react-art 5 | 6 | var RenderLayer = require('./RenderLayer'); 7 | 8 | function createComponent (name) { 9 | var ReactCanvasComponent = function (element) { 10 | this.node = null; 11 | this.subscriptions = null; 12 | this.listeners = null; 13 | this.node = new RenderLayer(); 14 | this._mountImage = null; 15 | this._currentElement = element; 16 | this._renderedChildren = null; 17 | this._mostRecentlyPlacedChild = null; 18 | }; 19 | ReactCanvasComponent.displayName = name; 20 | for (var i = 1, l = arguments.length; i < l; i++) { 21 | Object.assign(ReactCanvasComponent.prototype, arguments[i]); 22 | } 23 | 24 | return ReactCanvasComponent; 25 | } 26 | 27 | module.exports = createComponent; 28 | -------------------------------------------------------------------------------- /lib/hitTest.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var FrameUtils = require('./FrameUtils'); 4 | var EventTypes = require('./EventTypes'); 5 | 6 | /** 7 | * RenderLayer hit testing 8 | * 9 | * @param {Event} e 10 | * @param {RenderLayer} rootLayer 11 | * @param {?HTMLElement} rootNode 12 | * @return {RenderLayer} 13 | */ 14 | function hitTest (e, rootLayer, rootNode) { 15 | var touch = e.touches ? e.touches[0] : e; 16 | var touchX = touch.pageX; 17 | var touchY = touch.pageY; 18 | var rootNodeBox; 19 | if (rootNode) { 20 | rootNodeBox = rootNode.getBoundingClientRect(); 21 | touchX -= rootNodeBox.left; 22 | touchY -= rootNodeBox.top; 23 | } 24 | 25 | touchY = touchY - window.pageYOffset; 26 | touchX = touchX - window.pageXOffset; 27 | return getLayerAtPoint( 28 | rootLayer, 29 | e.type, 30 | FrameUtils.make(touchX, touchY, 1, 1), 31 | rootLayer.translateX || 0, 32 | rootLayer.translateY || 0 33 | ); 34 | } 35 | 36 | /** 37 | * @private 38 | */ 39 | function sortByZIndexDescending (layer, otherLayer) { 40 | return (otherLayer.zIndex || 0) - (layer.zIndex || 0); 41 | } 42 | 43 | /** 44 | * @private 45 | */ 46 | function getHitHandle (type) { 47 | var hitHandle; 48 | for (var tryHandle in EventTypes) { 49 | if (EventTypes[tryHandle] === type) { 50 | hitHandle = tryHandle; 51 | break; 52 | } 53 | } 54 | return hitHandle; 55 | } 56 | 57 | /** 58 | * @private 59 | */ 60 | function getLayerAtPoint (root, type, point, tx, ty) { 61 | var layer = null; 62 | var hitHandle = getHitHandle(type); 63 | var sortedChildren; 64 | var hitFrame = FrameUtils.clone(root.frame); 65 | 66 | // Early bail for non-visible layers 67 | if (typeof root.alpha === 'number' && root.alpha < 0.01) { 68 | return null; 69 | } 70 | 71 | // Child-first search 72 | if (root.children) { 73 | sortedChildren = root.children.slice().reverse().sort(sortByZIndexDescending); 74 | for (var i=0, len=sortedChildren.length; i < len; i++) { 75 | layer = getLayerAtPoint( 76 | sortedChildren[i], 77 | type, 78 | point, 79 | tx + (root.translateX || 0), 80 | ty + (root.translateY || 0) 81 | ); 82 | if (layer) { 83 | break; 84 | } 85 | } 86 | } 87 | 88 | // Check for hit outsets 89 | if (root.hitOutsets) { 90 | hitFrame = FrameUtils.inset(FrameUtils.clone(hitFrame), 91 | -root.hitOutsets[0], -root.hitOutsets[1], 92 | -root.hitOutsets[2], -root.hitOutsets[3] 93 | ); 94 | } 95 | 96 | // Check for x/y translation 97 | if (tx) { 98 | hitFrame.x += tx; 99 | } 100 | 101 | if (ty) { 102 | hitFrame.y += ty; 103 | } 104 | 105 | // No child layer at the given point. Try the parent layer. 106 | if (!layer && root[hitHandle] && FrameUtils.intersects(hitFrame, point)) { 107 | layer = root; 108 | } 109 | 110 | return layer; 111 | } 112 | 113 | module.exports = hitTest; 114 | module.exports.getHitHandle = getHitHandle; 115 | 116 | -------------------------------------------------------------------------------- /lib/layoutNode.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var computeLayout = require('./Layout'); 4 | 5 | /** 6 | * This computes the CSS layout for a RenderLayer tree and mutates the frame 7 | * objects at each node. 8 | * 9 | * @param {Renderlayer} root 10 | * @return {Object} 11 | */ 12 | function layoutNode (root) { 13 | var rootNode = createNode(root); 14 | computeLayout(rootNode); 15 | walkNode(rootNode); 16 | return rootNode; 17 | } 18 | 19 | function createNode (layer) { 20 | return { 21 | layer: layer, 22 | layout: { 23 | width: undefined, // computeLayout will mutate 24 | height: undefined, // computeLayout will mutate 25 | top: 0, 26 | left: 0, 27 | }, 28 | style: layer._originalStyle || {}, 29 | children: (layer.children || []).map(createNode) 30 | }; 31 | } 32 | 33 | function walkNode (node, parentLeft, parentTop) { 34 | node.layer.frame.x = node.layout.left + (parentLeft || 0); 35 | node.layer.frame.y = node.layout.top + (parentTop || 0); 36 | node.layer.frame.width = node.layout.width; 37 | node.layer.frame.height = node.layout.height; 38 | if (node.children && node.children.length > 0) { 39 | node.children.forEach(function (child) { 40 | walkNode(child, node.layer.frame.x, node.layer.frame.y); 41 | }); 42 | } 43 | } 44 | 45 | module.exports = layoutNode; 46 | -------------------------------------------------------------------------------- /lib/measureText.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var FontFace = require('./FontFace'); 4 | var FontUtils = require('./FontUtils'); 5 | var LineBreaker = require('linebreak'); 6 | 7 | var canvas = document.createElement('canvas'); 8 | var ctx = canvas.getContext('2d'); 9 | 10 | var _cache = {}; 11 | var _zeroMetrics = { 12 | width: 0, 13 | height: 0, 14 | lines: [] 15 | }; 16 | 17 | function getCacheKey (text, width, fontFace, fontSize, lineHeight) { 18 | return text + width + fontFace.id + fontSize + lineHeight; 19 | } 20 | 21 | /** 22 | * Given a string of text, available width, and font return the measured width 23 | * and height. 24 | * @param {String} text The input string 25 | * @param {Number} width The available width 26 | * @param {FontFace} fontFace The FontFace to use 27 | * @param {Number} fontSize The font size in CSS pixels 28 | * @param {Number} lineHeight The line height in CSS pixels 29 | * @return {Object} Measured text size with `width` and `height` members. 30 | */ 31 | module.exports = function measureText (text, width, fontFace, fontSize, lineHeight) { 32 | var cacheKey = getCacheKey(text, width, fontFace, fontSize, lineHeight); 33 | var cached = _cache[cacheKey]; 34 | if (cached) { 35 | return cached; 36 | } 37 | 38 | // Bail and return zero unless we're sure the font is ready. 39 | if (!FontUtils.isFontLoaded(fontFace)) { 40 | return _zeroMetrics; 41 | } 42 | 43 | var measuredSize = {}; 44 | var textMetrics; 45 | var lastMeasuredWidth; 46 | var words; 47 | var tryLine; 48 | var currentLine; 49 | var breaker; 50 | var bk; 51 | var lastBreak; 52 | 53 | ctx.font = fontFace.attributes.style + ' ' + fontFace.attributes.weight + ' ' + fontSize + 'px ' + fontFace.family; 54 | textMetrics = ctx.measureText(text); 55 | 56 | measuredSize.width = textMetrics.width; 57 | measuredSize.height = lineHeight; 58 | measuredSize.lines = []; 59 | 60 | if (measuredSize.width <= width) { 61 | // The entire text string fits. 62 | measuredSize.lines.push({width: measuredSize.width, text: text}); 63 | } else { 64 | // Break into multiple lines. 65 | measuredSize.width = width; 66 | currentLine = ''; 67 | breaker = new LineBreaker(text); 68 | 69 | while (bk = breaker.nextBreak()) { 70 | var word = text.slice(lastBreak ? lastBreak.position : 0, bk.position); 71 | 72 | tryLine = currentLine + word; 73 | textMetrics = ctx.measureText(tryLine); 74 | if (textMetrics.width > width || (lastBreak && lastBreak.required)) { 75 | measuredSize.height += lineHeight; 76 | measuredSize.lines.push({width: lastMeasuredWidth, text: currentLine.trim()}); 77 | currentLine = word; 78 | lastMeasuredWidth = ctx.measureText(currentLine.trim()).width; 79 | } else { 80 | currentLine = tryLine; 81 | lastMeasuredWidth = textMetrics.width; 82 | } 83 | 84 | lastBreak = bk; 85 | } 86 | 87 | currentLine = currentLine.trim(); 88 | if (currentLine.length > 0) { 89 | textMetrics = ctx.measureText(currentLine); 90 | measuredSize.lines.push({width: textMetrics, text: currentLine}); 91 | } 92 | } 93 | 94 | _cache[cacheKey] = measuredSize; 95 | 96 | return measuredSize; 97 | }; 98 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-canvas", 3 | "version": "1.3.0", 4 | "description": "High performance rendering for React components", 5 | "main": "lib/ReactCanvas.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/Flipboard/react-canvas.git" 9 | }, 10 | "scripts": { 11 | "start": "./node_modules/.bin/gulp", 12 | "test": "./node_modules/.bin/jest" 13 | }, 14 | "keywords": [ 15 | "react", 16 | "canvas" 17 | ], 18 | "author": "Michael Johnston ", 19 | "license": "BSD-3-Clause", 20 | "homepage": "https://github.com/Flipboard/react-canvas", 21 | "bugs": { 22 | "url": "https://github.com/Flipboard/react-canvas/issues" 23 | }, 24 | "devDependencies": { 25 | "babel-core": "^6.22.1", 26 | "babel-loader": "^6.2.10", 27 | "babel-preset-react": "^6.22.0", 28 | "brfs": "^1.4.3", 29 | "del": "^2.2.2", 30 | "envify": "^4.0.0", 31 | "gulp": "^3.9.1", 32 | "gulp-connect": "^5.0.0", 33 | "jest": "^18.1.0", 34 | "react": "^15.0.0", 35 | "react-dom": "^15.0.0", 36 | "transform-loader": "^0.2.3", 37 | "webpack": "^1.14.0", 38 | "webpack-stream": "^3.2.0" 39 | }, 40 | "peerDependencies": { 41 | "react": "^15.0.0" 42 | }, 43 | "dependencies": { 44 | "fbjs": "^0.8.8", 45 | "linebreak": "^0.3.0", 46 | "scroller": "git://github.com/mjohnston/scroller" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | cache: true, 3 | 4 | watch: true, 5 | 6 | entry: { 7 | 'listview': ['./examples/listview/app.js'], 8 | 'timeline': ['./examples/timeline/app.js'], 9 | 'gradient': ['./examples/gradient/app.js'], 10 | 'css-layout': ['./examples/css-layout/app.js'] 11 | }, 12 | 13 | output: { 14 | filename: '[name].js' 15 | }, 16 | 17 | module: { 18 | loaders: [ 19 | { test: /\.js$/, loader: 'babel-loader!transform/cacheable?envify' }, 20 | ], 21 | postLoaders: [ 22 | { loader: "transform?brfs" } 23 | ] 24 | }, 25 | devtool: ['source-map'], 26 | resolve: { 27 | root: __dirname, 28 | alias: { 29 | 'react-canvas': 'lib/ReactCanvas.js' 30 | } 31 | } 32 | }; 33 | --------------------------------------------------------------------------------