commit efae175a8e1b05c976cc5a1cbd492da71eb3bb12 Author: Spencer Ahrens Date: Thu Feb 19 20:10:52 2015 -0800 [react-packager][streamline oss] Move open sourced JS source to react-native-github diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000000..15261d31c4 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +**/node_modules/**/.*js +**/staticBundle.js +**/main.js diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000000..b037ffe488 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,192 @@ +{ + "env": { + "jasmine": true, + }, + + // Map from global var to bool specifying if it can be redefined + "globals": { + "__DEV__": true, + "__dirname": false, + "__fbBatchedBridgeConfig": false, + "cancelAnimationFrame": false, + "clearImmediate": true, + "clearInterval": false, + "clearTimeout": false, + "console": false, + "document": false, + "escape": false, + "exports": false, + "global": false, + "jest": false, + "Map": true, + "module": false, + "process": false, + "Promise": false, + "requestAnimationFrame": true, + "require": false, + "Set": true, + "setImmediate": true, + "setInterval": false, + "setTimeout": false, + "window": false, + "XMLHttpRequest": false + }, + + "rules": { + "no-cond-assign": 1, // disallow assignment in conditional expressions + "no-console": 0, // disallow use of console (off by default in the node environment) + "no-constant-condition": 1, // disallow use of constant expressions in conditions + "no-comma-dangle": 0, // disallow trailing commas in object literals + "no-control-regex": 1, // disallow control characters in regular expressions + "no-debugger": 1, // disallow use of debugger + "no-dupe-keys": 1, // disallow duplicate keys when creating object literals + "no-empty": 0, // disallow empty statements + "no-empty-class": 1, // disallow the use of empty character classes in regular expressions + "no-ex-assign": 1, // disallow assigning to the exception in a catch block + "no-extra-boolean-cast": 1, // disallow double-negation boolean casts in a boolean context + "no-extra-parens": 0, // disallow unnecessary parentheses (off by default) + "no-extra-semi": 1, // disallow unnecessary semicolons + "no-func-assign": 1, // disallow overwriting functions written as function declarations + "no-inner-declarations": 0, // disallow function or variable declarations in nested blocks + "no-invalid-regexp": 1, // disallow invalid regular expression strings in the RegExp constructor + "no-negated-in-lhs": 1, // disallow negation of the left operand of an in expression + "no-obj-calls": 1, // disallow the use of object properties of the global object (Math and JSON) as functions + "no-regex-spaces": 1, // disallow multiple spaces in a regular expression literal + "no-reserved-keys": 0, // disallow reserved words being used as object literal keys (off by default) + "no-sparse-arrays": 1, // disallow sparse arrays + "no-unreachable": 1, // disallow unreachable statements after a return, throw, continue, or break statement + "use-isnan": 1, // disallow comparisons with the value NaN + "valid-jsdoc": 0, // Ensure JSDoc comments are valid (off by default) + "valid-typeof": 1, // Ensure that the results of typeof are compared against a valid string + + // Best Practices + // These are rules designed to prevent you from making mistakes. They either prescribe a better way of doing something or help you avoid footguns. + + "block-scoped-var": 0, // treat var statements as if they were block scoped (off by default) + "complexity": 0, // specify the maximum cyclomatic complexity allowed in a program (off by default) + "consistent-return": 0, // require return statements to either always or never specify values + "curly": 1, // specify curly brace conventions for all control statements + "default-case": 0, // require default case in switch statements (off by default) + "dot-notation": 1, // encourages use of dot notation whenever possible + "eqeqeq": 1, // require the use of === and !== + "guard-for-in": 0, // make sure for-in loops have an if statement (off by default) + "no-alert": 1, // disallow the use of alert, confirm, and prompt + "no-caller": 1, // disallow use of arguments.caller or arguments.callee + "no-div-regex": 1, // disallow division operators explicitly at beginning of regular expression (off by default) + "no-else-return": 0, // disallow else after a return in an if (off by default) + "no-empty-label": 1, // disallow use of labels for anything other then loops and switches + "no-eq-null": 0, // disallow comparisons to null without a type-checking operator (off by default) + "no-eval": 1, // disallow use of eval() + "no-extend-native": 1, // disallow adding to native types + "no-extra-bind": 1, // disallow unnecessary function binding + "no-fallthrough": 1, // disallow fallthrough of case statements + "no-floating-decimal": 1, // disallow the use of leading or trailing decimal points in numeric literals (off by default) + "no-implied-eval": 1, // disallow use of eval()-like methods + "no-labels": 1, // disallow use of labeled statements + "no-iterator": 1, // disallow usage of __iterator__ property + "no-lone-blocks": 1, // disallow unnecessary nested blocks + "no-loop-func": 0, // disallow creation of functions within loops + "no-multi-str": 0, // disallow use of multiline strings + "no-native-reassign": 0, // disallow reassignments of native objects + "no-new": 1, // disallow use of new operator when not part of the assignment or comparison + "no-new-func": 1, // disallow use of new operator for Function object + "no-new-wrappers": 1, // disallows creating new instances of String,Number, and Boolean + "no-octal": 1, // disallow use of octal literals + "no-octal-escape": 1, // disallow use of octal escape sequences in string literals, such as var foo = "Copyright \251"; + "no-proto": 1, // disallow usage of __proto__ property + "no-redeclare": 0, // disallow declaring the same variable more then once + "no-return-assign": 1, // disallow use of assignment in return statement + "no-script-url": 1, // disallow use of javascript: urls. + "no-self-compare": 1, // disallow comparisons where both sides are exactly the same (off by default) + "no-sequences": 1, // disallow use of comma operator + "no-unused-expressions": 0, // disallow usage of expressions in statement position + "no-void": 1, // disallow use of void operator (off by default) + "no-warning-comments": 0, // disallow usage of configurable warning terms in comments": 1, // e.g. TODO or FIXME (off by default) + "no-with": 1, // disallow use of the with statement + "radix": 1, // require use of the second argument for parseInt() (off by default) + "vars-on-top": 0, // requires to declare all vars on top of their containing scope (off by default) + "wrap-iife": 0, // require immediate function invocation to be wrapped in parentheses (off by default) + "yoda": 1, // require or disallow Yoda conditions + + // Strict Mode + // These rules relate to using strict mode. + + "global-strict": [2, "always"], // require or disallow the "use strict" pragma in the global scope (off by default in the node environment) + "no-extra-strict": 1, // disallow unnecessary use of "use strict"; when already in strict mode + "strict": 0, // require that all functions are run in strict mode + + // Variables + // These rules have to do with variable declarations. + + "no-catch-shadow": 1, // disallow the catch clause parameter name being the same as a variable in the outer scope (off by default in the node environment) + "no-delete-var": 1, // disallow deletion of variables + "no-label-var": 1, // disallow labels that share a name with a variable + "no-shadow": 1, // disallow declaration of variables already declared in the outer scope + "no-shadow-restricted-names": 1, // disallow shadowing of names such as arguments + "no-undef": 2, // disallow use of undeclared variables unless mentioned in a /*global */ block + "no-undefined": 0, // disallow use of undefined variable (off by default) + "no-undef-init": 1, // disallow use of undefined when initializing variables + "no-unused-vars": [1, {"vars": "all", "args": "none"}], // disallow declaration of variables that are not used in the code + "no-use-before-define": 0, // disallow use of variables before they are defined + + // Node.js + // These rules are specific to JavaScript running on Node.js. + + "handle-callback-err": 1, // enforces error handling in callbacks (off by default) (on by default in the node environment) + "no-mixed-requires": 1, // disallow mixing regular variable and require declarations (off by default) (on by default in the node environment) + "no-new-require": 1, // disallow use of new operator with the require function (off by default) (on by default in the node environment) + "no-path-concat": 1, // disallow string concatenation with __dirname and __filename (off by default) (on by default in the node environment) + "no-process-exit": 0, // disallow process.exit() (on by default in the node environment) + "no-restricted-modules": 1, // restrict usage of specified node modules (off by default) + "no-sync": 0, // disallow use of synchronous methods (off by default) + + // Stylistic Issues + // These rules are purely matters of style and are quite subjective. + + "key-spacing": 0, + "comma-spacing": 0, + "no-multi-spaces": 0, + "brace-style": 0, // enforce one true brace style (off by default) + "camelcase": 0, // require camel case names + "consistent-this": 1, // enforces consistent naming when capturing the current execution context (off by default) + "eol-last": 1, // enforce newline at the end of file, with no multiple empty lines + "func-names": 0, // require function expressions to have a name (off by default) + "func-style": 0, // enforces use of function declarations or expressions (off by default) + "new-cap": 0, // require a capital letter for constructors + "new-parens": 1, // disallow the omission of parentheses when invoking a constructor with no arguments + "no-nested-ternary": 0, // disallow nested ternary expressions (off by default) + "no-array-constructor": 1, // disallow use of the Array constructor + "no-lonely-if": 0, // disallow if as the only statement in an else block (off by default) + "no-new-object": 1, // disallow use of the Object constructor + "no-spaced-func": 1, // disallow space between function identifier and application + "no-space-before-semi": 1, // disallow space before semicolon + "no-ternary": 0, // disallow the use of ternary operators (off by default) + "no-trailing-spaces": 1, // disallow trailing whitespace at the end of lines + "no-underscore-dangle": 0, // disallow dangling underscores in identifiers + "no-wrap-func": 1, // disallow wrapping of non-IIFE statements in parens + "no-mixed-spaces-and-tabs": 1, // disallow mixed spaces and tabs for indentation + "quotes": [1, "single"], // specify whether double or single quotes should be used + "quote-props": 0, // require quotes around object literal property names (off by default) + "semi": 1, // require or disallow use of semicolons instead of ASI + "sort-vars": 0, // sort variables within the same declaration block (off by default) + "space-after-keywords": 1, // require a space after certain keywords (off by default) + "space-in-brackets": 0, // require or disallow spaces inside brackets (off by default) + "space-in-parens": 0, // require or disallow spaces inside parentheses (off by default) + "space-infix-ops": 1, // require spaces around operators + "space-return-throw-case": 1, // require a space after return, throw, and case + "space-unary-word-ops": 1, // require a space around word operators such as typeof (off by default) + "max-nested-callbacks": 0, // specify the maximum depth callbacks can be nested (off by default) + "one-var": 0, // allow just one var statement per function (off by default) + "wrap-regex": 0, // require regex literals to be wrapped in parentheses (off by default) + + // Legacy + // The following rules are included for compatibility with JSHint and JSLint. While the names of the rules may not match up with the JSHint/JSLint counterpart, the functionality is the same. + + "max-depth": 0, // specify the maximum depth that blocks can be nested (off by default) + "max-len": 0, // specify the maximum length of a line in your program (off by default) + "max-params": 0, // limits the number of parameters that can be used in the function declaration. (off by default) + "max-statements": 0, // specify the maximum number of statement allowed in a function (off by default) + "no-bitwise": 1, // disallow use of bitwise operators (off by default) + "no-plusplus": 0 // disallow use of unary operators, ++ and -- (off by default) + } +} diff --git a/.flowconfig b/.flowconfig new file mode 100644 index 0000000000..20bb687568 --- /dev/null +++ b/.flowconfig @@ -0,0 +1,29 @@ +[ignore] + +# We fork some components by platform. +.*/*.web.js +.*/*.android.js + +# Some modules have their own node_modules with overlap +.*/node_modules/node-haste/.* + +# Ignore react-tools where there are overlaps, but don't ignore anything that +# react-native relies on +.*/node_modules/react-tools/src/vendor/.* +.*/node_modules/react-tools/src/browser/.* +.*/node_modules/react-tools/src/core/ReactInstanceHandles.js +.*/node_modules/react-tools/src/event/.* + +# Ignore jest +.*/react-native/node_modules/jest-cli/.* + +# Ignore Libraries +.*/Libraries/.* + +[include] + +[libs] +Libraries/react-native/react-native-interface.js + +[options] +module.system=haste diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..37060d99a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate + +node_modules diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000..6e5919de39 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,3 @@ +language: node_js +node_js: + - "0.10" diff --git a/Examples/Movies/AppDelegate.h b/Examples/Movies/AppDelegate.h new file mode 100644 index 0000000000..f0ec66bdb5 --- /dev/null +++ b/Examples/Movies/AppDelegate.h @@ -0,0 +1,10 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import + +@interface AppDelegate : UIResponder + +@property (nonatomic, strong) UIWindow *window; + +@end + diff --git a/Examples/Movies/AppDelegate.m b/Examples/Movies/AppDelegate.m new file mode 100644 index 0000000000..c01fc2ca9f --- /dev/null +++ b/Examples/Movies/AppDelegate.m @@ -0,0 +1,44 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "AppDelegate.h" + +#import "RCTRootView.h" + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions +{ + NSURL *jsCodeLocation; + RCTRootView *rootView = [[RCTRootView alloc] init]; + + // Loading JavaScript code - uncomment the one you want. + + // OPTION 1 + // Load from development server. Start the server from the repository root: + // + // $ npm start + // + // To run on device, change `localhost` to the IP address of your computer, and make sure your computer and + // iOS device are on the same Wi-Fi network. + jsCodeLocation = [NSURL URLWithString:@"http://localhost:8081/Examples/Movies/MoviesApp.includeRequire.runModule.bundle"]; + + // OPTION 2 + // Load from pre-bundled file on disk. To re-generate the static bundle, run + // + // $ curl http://localhost:8081/Examples/Movies/MoviesApp.includeRequire.runModule.bundle -o main.jsbundle + // + // and uncomment the next following line + // jsCodeLocation = [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; + + rootView.scriptURL = jsCodeLocation; + rootView.moduleName = @"MoviesApp"; + + self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; + UIViewController *rootViewController = [[UIViewController alloc] init]; + rootViewController.view = rootView; + self.window.rootViewController = rootViewController; + [self.window makeKeyAndVisible]; + return YES; +} + +@end diff --git a/Examples/Movies/Base.lproj/LaunchScreen.xib b/Examples/Movies/Base.lproj/LaunchScreen.xib new file mode 100644 index 0000000000..d3f0b52a88 --- /dev/null +++ b/Examples/Movies/Base.lproj/LaunchScreen.xib @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/Movies/Images.xcassets/AppIcon.appiconset/Contents.json b/Examples/Movies/Images.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..118c98f746 --- /dev/null +++ b/Examples/Movies/Images.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,38 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Examples/Movies/Info.plist b/Examples/Movies/Info.plist new file mode 100644 index 0000000000..3c7e8c72e4 --- /dev/null +++ b/Examples/Movies/Info.plist @@ -0,0 +1,40 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + com.facebook.$(PRODUCT_NAME:rfc1034identifier) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UIViewControllerBasedStatusBarAppearance + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Examples/Movies/MovieCell.js b/Examples/Movies/MovieCell.js new file mode 100644 index 0000000000..749efa1d23 --- /dev/null +++ b/Examples/Movies/MovieCell.js @@ -0,0 +1,86 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * @flow + */ +'use strict'; + +var React = require('react-native'); +var { + Image, + PixelRatio, + StyleSheet, + Text, + TouchableHighlight, + View +} = React; + +var getStyleFromScore = require('./getStyleFromScore'); +var getImageSource = require('./getImageSource'); +var getTextFromScore = require('./getTextFromScore'); + +var MovieCell = React.createClass({ + render: function() { + var criticsScore = this.props.movie.ratings.critics_score; + return ( + + + + + + + {this.props.movie.title} + + + {this.props.movie.year} + {' '}•{' '} + + Critics {getTextFromScore(criticsScore)} + + + + + + + + ); + } +}); + +var styles = StyleSheet.create({ + textContainer: { + flex: 1, + }, + movieTitle: { + flex: 1, + fontSize: 16, + fontWeight: 'bold', + marginBottom: 2, + }, + movieYear: { + color: '#999999', + fontSize: 12, + }, + row: { + alignItems: 'center', + backgroundColor: 'white', + flexDirection: 'row', + padding: 5, + }, + cellImage: { + backgroundColor: '#dddddd', + height: 93, + marginRight: 10, + width: 60, + }, + cellBorder: { + backgroundColor: 'rgba(0, 0, 0, 0.1)', + // Trick to get the thinest line the device can display + height: 1 / PixelRatio.get(), + marginLeft: 4, + }, +}); + +module.exports = MovieCell; diff --git a/Examples/Movies/MovieScreen.js b/Examples/Movies/MovieScreen.js new file mode 100644 index 0000000000..53c8879dc0 --- /dev/null +++ b/Examples/Movies/MovieScreen.js @@ -0,0 +1,155 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * @flow + */ +'use strict'; + +var React = require('react-native'); +var { + ExpandingText, + Image, + PixelRatio, + ScrollView, + StyleSheet, + Text, + View, +} = React; + +var getImageSource = require('./getImageSource'); +var getStyleFromScore = require('./getStyleFromScore'); +var getTextFromScore = require('./getTextFromScore'); + +var MovieScreen = React.createClass({ + render: function() { + return ( + + + + + {this.props.movie.title} + {this.props.movie.year} + + + {this.props.movie.mpaa_rating} + + + + + + + + + + + ); + }, +}); + +var Ratings = React.createClass({ + render: function() { + var criticsScore = this.props.ratings.critics_score; + var audienceScore = this.props.ratings.audience_score; + + return ( + + + Critics: + + {getTextFromScore(criticsScore)} + + + + Audience: + + {getTextFromScore(audienceScore)} + + + + ); + }, +}); + +var Cast = React.createClass({ + render: function() { + if (!this.props.actors) { + return null; + } + + return ( + + Actors + {this.props.actors.map(actor => + + • {actor.name} + + )} + + ); + }, +}); + +var styles = StyleSheet.create({ + contentContainer: { + padding: 10, + }, + rightPane: { + justifyContent: 'space-between', + flex: 1, + }, + movieTitle: { + flex: 1, + fontSize: 16, + fontWeight: 'bold', + }, + rating: { + marginTop: 10, + }, + ratingTitle: { + fontSize: 14, + }, + ratingValue: { + fontSize: 28, + fontWeight: 'bold', + }, + mpaaWrapper: { + alignSelf: 'flex-start', + borderColor: 'black', + borderWidth: 1, + paddingHorizontal: 3, + marginVertical: 5, + }, + mpaaText: { + fontFamily: 'Palatino', + fontSize: 13, + fontWeight: 'bold', + }, + mainSection: { + flexDirection: 'row', + }, + detailsImage: { + width: 134, + height: 200, + backgroundColor: '#eaeaea', + marginRight: 10, + }, + separator: { + backgroundColor: 'rgba(0, 0, 0, 0.1)', + height: 1 / PixelRatio.get(), + marginVertical: 10, + }, + castTitle: { + fontWeight: 'bold', + marginBottom: 3, + }, + castActor: { + marginLeft: 2, + }, +}); + +module.exports = MovieScreen; diff --git a/Examples/Movies/MoviesApp.js b/Examples/Movies/MoviesApp.js new file mode 100644 index 0000000000..768ad895ab --- /dev/null +++ b/Examples/Movies/MoviesApp.js @@ -0,0 +1,41 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule MoviesApp + * @flow + */ +'use strict'; + +var React = require('react-native/addons'); +var { + AppRegistry, + NavigatorIOS, + StyleSheet, +} = React; + +var SearchScreen = require('./SearchScreen'); + +var MoviesApp = React.createClass({ + render: function() { + return ( + + ); + } +}); + +var styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: 'white', + }, +}); + +AppRegistry.registerComponent('MoviesApp', () => MoviesApp); + +module.exports = MoviesApp; diff --git a/Examples/Movies/SearchScreen.js b/Examples/Movies/SearchScreen.js new file mode 100644 index 0000000000..2cd67aa192 --- /dev/null +++ b/Examples/Movies/SearchScreen.js @@ -0,0 +1,338 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * @flow + */ +'use strict'; + +var React = require('react-native'); +var { + ListView, + ListViewDataSource, + ScrollView, + ActivityIndicatorIOS, + StyleSheet, + Text, + TextInput, + TimerMixin, + View, +} = React; + +var MovieCell = require('./MovieCell'); +var MovieScreen = require('./MovieScreen'); + +var fetch = require('fetch'); + +var API_URL = 'http://api.rottentomatoes.com/api/public/v1.0/'; +var API_KEYS = ['7waqfqbprs7pajbz28mqf6vz', 'y4vwv8m33hed9ety83jmv52f']; + +// Results should be cached keyed by the query +// with values of null meaning "being fetched" +// and anything besides null and undefined +// as the result of a valid query +var resultsCache = { + dataForQuery: {}, + nextPageNumberForQuery: {}, + totalForQuery: {}, +}; + +var LOADING = {}; + +var SearchScreen = React.createClass({ + mixins: [TimerMixin], + + getInitialState: function() { + return { + isLoading: false, + isLoadingTail: false, + dataSource: new ListViewDataSource({ + rowHasChanged: (row1, row2) => row1 !== row2, + }), + filter: '', + queryNumber: 0, + }; + }, + + componentDidMount: function() { + this.searchMovies(''); + }, + + _urlForQueryAndPage: function(query: string, pageNumber: ?number): string { + var apiKey = API_KEYS[this.state.queryNumber % API_KEYS.length]; + if (query) { + return ( + API_URL + 'movies.json?apikey=' + apiKey + '&q=' + + encodeURIComponent(query) + '&page_limit=20&page=' + pageNumber + ); + } else { + // With no query, load latest movies + return ( + API_URL + 'lists/movies/in_theaters.json?apikey=' + apiKey + + '&page_limit=20&page=' + pageNumber + ); + } + }, + + searchMovies: function(query: string) { + this.timeoutID = null; + + this.setState({filter: query}); + + var cachedResultsForQuery = resultsCache.dataForQuery[query]; + if (cachedResultsForQuery) { + if (!LOADING[query]) { + this.setState({ + dataSource: this.getDataSource(cachedResultsForQuery), + isLoading: false + }); + } else { + this.setState({isLoading: true}); + } + return; + } + + LOADING[query] = true; + resultsCache.dataForQuery[query] = null; + this.setState({ + isLoading: true, + queryNumber: this.state.queryNumber + 1, + isLoadingTail: false, + }); + + fetch(this._urlForQueryAndPage(query, 1)) + .then((response) => response.json()) + .then((responseData) => { + LOADING[query] = false; + resultsCache.totalForQuery[query] = responseData.total; + resultsCache.dataForQuery[query] = responseData.movies; + resultsCache.nextPageNumberForQuery[query] = 2; + + if (this.state.filter !== query) { + // do not update state if the query is stale + return; + } + + this.setState({ + isLoading: false, + dataSource: this.getDataSource(responseData.movies), + }); + }) + .catch((error) => { + LOADING[query] = false; + resultsCache.dataForQuery[query] = undefined; + + this.setState({ + dataSource: this.getDataSource([]), + isLoading: false, + }); + }); + }, + + hasMore: function(): boolean { + var query = this.state.filter; + if (!resultsCache.dataForQuery[query]) { + return true; + } + return ( + resultsCache.totalForQuery[query] !== + resultsCache.dataForQuery[query].length + ); + }, + + onEndReached: function() { + var query = this.state.filter; + if (!this.hasMore() || this.state.isLoadingTail) { + // We're already fetching or have all the elements so noop + return; + } + + if (LOADING[query]) { + return; + } + + LOADING[query] = true; + this.setState({ + queryNumber: this.state.queryNumber + 1, + isLoadingTail: true, + }); + + var page = resultsCache.nextPageNumberForQuery[query]; + fetch(this._urlForQueryAndPage(query, page)) + .then((response) => response.json()) + .then((responseData) => { + var moviesForQuery = resultsCache.dataForQuery[query].slice(); + + LOADING[query] = false; + // We reached the end of the list before the expected number of results + if (!responseData.movies) { + resultsCache.totalForQuery[query] = moviesForQuery.length; + } else { + for (var i in responseData.movies) { + moviesForQuery.push(responseData.movies[i]); + } + resultsCache.dataForQuery[query] = moviesForQuery; + resultsCache.nextPageNumberForQuery[query] += 1; + } + + if (this.state.filter !== query) { + // do not update state if the query is stale + return; + } + + this.setState({ + isLoadingTail: false, + dataSource: this.getDataSource(resultsCache.dataForQuery[query]), + }); + }) + .catch((error) => { + console.error(error); + LOADING[query] = false; + this.setState({ + isLoadingTail: false, + }); + }); + }, + + getDataSource: function(movies: Array): ListViewDataSource { + return this.state.dataSource.cloneWithRows(movies); + }, + + selectMovie: function(movie: Object) { + this.props.navigator.push({ + title: movie.title, + component: MovieScreen, + passProps: {movie}, + }); + }, + + onSearchChange: function(event: Object) { + var filter = event.nativeEvent.text.toLowerCase(); + + this.clearTimeout(this.timeoutID); + this.timeoutID = this.setTimeout(() => this.searchMovies(filter), 100); + }, + + renderFooter: function() { + if (!this.hasMore() || !this.state.isLoadingTail) { + return ; + } + return ; + }, + + renderRow: function(movie: Object) { + return ( + this.selectMovie(movie)} + movie={movie} + /> + ); + }, + + render: function() { + var content = this.state.dataSource.getRowCount() === 0 ? + : + ; + + return ( + + this.refs.listview.getScrollResponder().scrollTo(0, 0)} + /> + + {content} + + ); + }, +}); + +var NoMovies = React.createClass({ + render: function() { + var text = ''; + if (this.props.filter) { + text = `No results for “${this.props.filter}”`; + } else if (!this.props.isLoading) { + // If we're looking at the latest movies, aren't currently loading, and + // still have no results, show a message + text = 'No movies found'; + } + + return ( + + {text} + + ); + } +}); + +var SearchBar = React.createClass({ + render: function() { + return ( + + + + + ); + } +}); + +var styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: 'white', + }, + centerText: { + alignItems: 'center', + }, + noMoviesText: { + marginTop: 80, + color: '#888888', + }, + searchBar: { + marginTop: 64, + padding: 3, + paddingLeft: 8, + flexDirection: 'row', + alignItems: 'center', + }, + searchBarInput: { + fontSize: 15, + flex: 1, + height: 30, + }, + separator: { + height: 1, + backgroundColor: '#eeeeee', + }, + spinner: { + width: 30, + }, + scrollSpinner: { + marginVertical: 20, + }, +}); + +module.exports = SearchScreen; diff --git a/Examples/Movies/__tests__/getImageSource-test.js b/Examples/Movies/__tests__/getImageSource-test.js new file mode 100644 index 0000000000..d4a2e9df1b --- /dev/null +++ b/Examples/Movies/__tests__/getImageSource-test.js @@ -0,0 +1,36 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + */ +'use strict'; + +jest.dontMock('../getImageSource'); + +var getImageSource = require('../getImageSource'); + +describe('getImageSource', () => { + it('returns null for invalid input', () => { + expect(getImageSource().uri).toBe(null); + }); + + it('returns a movie thumbnail', () => { + var uri = 'https://facebook.com'; + var source = { + posters: { + thumbnail: uri, + }, + }; + expect(getImageSource(source).uri).toBe(uri); + }); + + it('returns a movie thumbnail with kind', () => { + var uri = 'https://facebook.com?tmb'; + var source = { + posters: { + thumbnail: uri, + }, + }; + expect(getImageSource(source, 'kind').uri).toBe( + 'https://facebook.com?kind' + ); + }); +}); diff --git a/Examples/Movies/getImageSource.js b/Examples/Movies/getImageSource.js new file mode 100644 index 0000000000..b01cc286a4 --- /dev/null +++ b/Examples/Movies/getImageSource.js @@ -0,0 +1,15 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * @flow + */ +'use strict'; + +function getImageSource(movie: Object, kind: ?string): {uri: ?string} { + var uri = movie && movie.posters ? movie.posters.thumbnail : null; + if (uri && kind) { + uri = uri.replace('tmb', kind); + } + return { uri }; +} + +module.exports = getImageSource; diff --git a/Examples/Movies/getStyleFromScore.js b/Examples/Movies/getStyleFromScore.js new file mode 100644 index 0000000000..37252095e2 --- /dev/null +++ b/Examples/Movies/getStyleFromScore.js @@ -0,0 +1,35 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * @flow + */ +'use strict'; + +var React = require('react-native'); +var { + StyleSheet, +} = React; + +var MAX_VALUE = 200; + +function getStyleFromScore(score: number): {color: string} { + if (score < 0) { + return styles.noScore; + } + + var normalizedScore = Math.round((score / 100) * MAX_VALUE); + return { + color: 'rgb(' + + (MAX_VALUE - normalizedScore) + ', ' + + normalizedScore + ', ' + + 0 + + ')' + }; +} + +var styles = StyleSheet.create({ + noScore: { + color: '#999999', + }, +}); + +module.exports = getStyleFromScore; diff --git a/Examples/Movies/getTextFromScore.js b/Examples/Movies/getTextFromScore.js new file mode 100644 index 0000000000..d0ad6d31cd --- /dev/null +++ b/Examples/Movies/getTextFromScore.js @@ -0,0 +1,11 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * @flow + */ +'use strict'; + +function getTextFromScore(score: number): string { + return score > 0 ? score + '%' : 'N/A'; +} + +module.exports = getTextFromScore; diff --git a/Examples/Movies/main.m b/Examples/Movies/main.m new file mode 100644 index 0000000000..357a233b12 --- /dev/null +++ b/Examples/Movies/main.m @@ -0,0 +1,10 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import +#import "AppDelegate.h" + +int main(int argc, char * argv[]) { + @autoreleasepool { + return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); + } +} diff --git a/Examples/TicTacToe/AppDelegate.h b/Examples/TicTacToe/AppDelegate.h new file mode 100644 index 0000000000..062fb99c00 --- /dev/null +++ b/Examples/TicTacToe/AppDelegate.h @@ -0,0 +1,9 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import + +@interface AppDelegate : UIResponder + +@property (nonatomic, strong) UIWindow *window; + +@end diff --git a/Examples/TicTacToe/AppDelegate.m b/Examples/TicTacToe/AppDelegate.m new file mode 100644 index 0000000000..52e6827524 --- /dev/null +++ b/Examples/TicTacToe/AppDelegate.m @@ -0,0 +1,44 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "AppDelegate.h" + +#import "RCTRootView.h" + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions +{ + NSURL *jsCodeLocation; + RCTRootView *rootView = [[RCTRootView alloc] init]; + + // Loading JavaScript code - uncomment the one you want. + + // OPTION 1 + // Load from development server. Start the server from the repository root: + // + // $ npm start + // + // To run on device, change `localhost` to the IP address of your computer, and make sure your computer and + // iOS device are on the same Wi-Fi network. + jsCodeLocation = [NSURL URLWithString:@"http://localhost:8081/Examples/TicTacToe/TicTacToeApp.includeRequire.runModule.bundle"]; + + // OPTION 2 + // Load from pre-bundled file on disk. To re-generate the static bundle, run + // + // $ curl http://localhost:8081/Examples/TicTacToe/TicTacToeApp.includeRequire.runModule.bundle -o main.jsbundle + // + // and uncomment the next following line + // jsCodeLocation = [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; + + rootView.scriptURL = jsCodeLocation; + rootView.moduleName = @"TicTacToeApp"; + + self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; + UIViewController *rootViewController = [[UIViewController alloc] init]; + rootViewController.view = rootView; + self.window.rootViewController = rootViewController; + [self.window makeKeyAndVisible]; + return YES; +} + +@end diff --git a/Examples/TicTacToe/Base.lproj/LaunchScreen.xib b/Examples/TicTacToe/Base.lproj/LaunchScreen.xib new file mode 100644 index 0000000000..c2d4e90790 --- /dev/null +++ b/Examples/TicTacToe/Base.lproj/LaunchScreen.xib @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/TicTacToe/Images.xcassets/AppIcon.appiconset/Contents.json b/Examples/TicTacToe/Images.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..118c98f746 --- /dev/null +++ b/Examples/TicTacToe/Images.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,38 @@ +{ + "images" : [ + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "29x29", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "40x40", + "scale" : "3x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "2x" + }, + { + "idiom" : "iphone", + "size" : "60x60", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Examples/TicTacToe/Info.plist b/Examples/TicTacToe/Info.plist new file mode 100644 index 0000000000..9a7ca7e3cc --- /dev/null +++ b/Examples/TicTacToe/Info.plist @@ -0,0 +1,40 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + com.facebook.$(PRODUCT_NAME:rfc1034identifier) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/Examples/TicTacToe/TicTacToeApp.js b/Examples/TicTacToe/TicTacToeApp.js new file mode 100755 index 0000000000..913123c454 --- /dev/null +++ b/Examples/TicTacToe/TicTacToeApp.js @@ -0,0 +1,322 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule TicTacToeApp + * @flow + */ +'use strict'; + +var React = require('react-native'); +var { + AppRegistry, + Image, + StyleSheet, + Text, + TouchableHighlight, + View, +} = React; + +class Board { + grid: Array>; + turn: number; + + constructor() { + var size = 3; + var grid = Array(size); + for (var i = 0; i < size; i++) { + var row = Array(size); + for (var j = 0; j < size; j++) { + row[j] = 0; + } + grid[i] = row; + } + this.grid = grid; + + this.turn = 1; + } + + mark(row: number, col: number, player: number): Board { + this.grid[row][col] = player; + return this; + } + + hasMark(row: number, col: number): boolean { + return this.grid[row][col] !== 0; + } + + winner(): ?number { + for (var i = 0; i < 3; i++) { + if (this.grid[i][0] !== 0 && this.grid[i][0] === this.grid[i][1] && + this.grid[i][0] === this.grid[i][2]) { + return this.grid[i][0]; + } + } + + for (var i = 0; i < 3; i++) { + if (this.grid[0][i] !== 0 && this.grid[0][i] === this.grid[1][i] && + this.grid[0][i] === this.grid[2][i]) { + return this.grid[0][i]; + } + } + + if (this.grid[0][0] !== 0 && this.grid[0][0] === this.grid[1][1] && + this.grid[0][0] === this.grid[2][2]) { + return this.grid[0][0]; + } + + if (this.grid[0][2] !== 0 && this.grid[0][2] === this.grid[1][1] && + this.grid[0][2] === this.grid[2][0]) { + return this.grid[0][2]; + } + + return null; + } + + tie(): boolean { + for (var i = 0; i < 3; i++) { + for (var j = 0; j < 3; j++) { + if (this.grid[i][j] === 0) { + return false; + } + } + } + + return this.winner() === null; + } +} + +var Cell = React.createClass({ + cellStyle() { + switch (this.props.player) { + case 1: + return styles.cellX; + case 2: + return styles.cellO; + default: + return null; + } + }, + + textStyle() { + switch (this.props.player) { + case 1: + return styles.cellTextX; + case 2: + return styles.cellTextO; + default: + return {}; + } + }, + + textContents() { + switch (this.props.player) { + case 1: + return 'X'; + case 2: + return 'O'; + default: + return ''; + } + }, + + imageContents() { + switch (this.props.player) { + case 1: + return 'http://www.picgifs.com/alphabets/alphabets/children-5/alphabets-children-5-277623.gif'; + case 2: + return 'http://www.picgifs.com/alphabets/alphabets/children-5/alphabets-children-5-730492.gif'; + default: + return ''; + } + }, + + render() { + return ( + + + + + + ); + } +}); + +var GameEndOverlay = React.createClass({ + render() { + var board = this.props.board; + + var tie = board.tie(); + var winner = board.winner(); + if (!winner && !tie) { + return ; + } + + var message; + if (tie) { + message = 'It\'s a tie!'; + } else { + message = (winner === 1 ? 'X' : 'O') + ' wins!'; + } + + return ( + + {message} + + + New Game + + + + ); + } +}); + +var TicTacToeApp = React.createClass({ + getInitialState() { + return { board: new Board(), player: 1 }; + }, + + restartGame() { + this.setState(this.getInitialState()); + }, + + nextPlayer(): number { + return this.state.player === 1 ? 2 : 1; + }, + + handleCellPress(row: number, col: number) { + if (this.state.board.hasMark(row, col)) { + return; + } + + this.setState({ + board: this.state.board.mark(row, col, this.state.player), + player: this.nextPlayer(), + }); + }, + + render() { + var rows = this.state.board.grid.map((cells, row) => + + {cells.map((player, col) => + + )} + + ); + + return ( + + EXTREME T3 + + {rows} + + + + ); + } +}); + +var styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'white' + }, + title: { + fontFamily: 'Chalkduster', + fontSize: 39, + marginBottom: 20, + }, + board: { + padding: 5, + backgroundColor: '#47525d', + borderRadius: 10, + }, + row: { + flexDirection: 'row', + }, + + // CELL + + cell: { + width: 80, + height: 80, + borderRadius: 5, + backgroundColor: '#7b8994', + margin: 5, + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + cellX: { + backgroundColor: '#72d0eb', + }, + cellO: { + backgroundColor: '#7ebd26', + }, + + // CELL TEXT + + cellText: { + borderRadius: 5, + fontSize: 50, + fontFamily: 'AvenirNext-Bold', + }, + cellTextX: { + color: '#19a9e5', + }, + cellTextO: { + color: '#b9dc2f', + }, + + // GAME OVER + + overlay: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + right: 0, + backgroundColor: 'rgba(221, 221, 221, 0.5)', + flex: 1, + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + }, + overlayMessage: { + fontSize: 40, + marginBottom: 20, + marginLeft: 20, + marginRight: 20, + fontFamily: 'AvenirNext-DemiBold', + textAlign: 'center', + }, + newGame: { + backgroundColor: '#887766', + padding: 20, + borderRadius: 5, + }, + newGameText: { + color: 'white', + fontSize: 20, + fontFamily: 'AvenirNext-DemiBold', + }, +}); + +AppRegistry.registerComponent('TicTacToeApp', () => TicTacToeApp); + +module.exports = TicTacToeApp; diff --git a/Examples/TicTacToe/main.m b/Examples/TicTacToe/main.m new file mode 100644 index 0000000000..357a233b12 --- /dev/null +++ b/Examples/TicTacToe/main.m @@ -0,0 +1,10 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import +#import "AppDelegate.h" + +int main(int argc, char * argv[]) { + @autoreleasepool { + return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); + } +} diff --git a/Examples/UIExplorer/ActivityIndicatorExample.js b/Examples/UIExplorer/ActivityIndicatorExample.js new file mode 100644 index 0000000000..4f068254de --- /dev/null +++ b/Examples/UIExplorer/ActivityIndicatorExample.js @@ -0,0 +1,151 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ActivityIndicatorExample + */ +'use strict'; + +var React = require('react-native'); +var { + ActivityIndicatorIOS, + StyleSheet, + TimerMixin, + View, +} = React; + +var ToggleAnimatingActivityIndicator = React.createClass({ + mixins: [TimerMixin], + + getInitialState: function() { + return { + animating: true, + }; + }, + + setToggleTimeout: function() { + this.setTimeout( + () => { + this.setState({animating: !this.state.animating}); + this.setToggleTimeout(); + }, + 1200 + ); + }, + + componentDidMount: function() { + this.setToggleTimeout(); + }, + + render: function() { + return ( + + ); + } +}); + +exports.framework = 'React'; +exports.title = ''; +exports.description = 'Animated loading indicators.'; + +exports.examples = [ + { + title: 'Default (small, white)', + render: function() { + return ( + + ); + } + }, + { + title: 'Gray', + render: function() { + return ( + + + + + ); + } + }, + { + title: 'Custom colors', + render: function() { + return ( + + + + + + + ); + } + }, + { + title: 'Large', + render: function() { + return ( + + ); + } + }, + { + title: 'Large, custom colors', + render: function() { + return ( + + + + + + + ); + } + }, + { + title: 'Start/stop', + render: function() { + return ; + } + }, +]; + +var styles = StyleSheet.create({ + centering: { + alignItems: 'center', + justifyContent: 'center', + }, + gray: { + backgroundColor: '#cccccc', + }, + horizontal: { + flexDirection: 'row', + justifyContent: 'space-around', + }, +}); diff --git a/Examples/UIExplorer/AppDelegate.h b/Examples/UIExplorer/AppDelegate.h new file mode 100644 index 0000000000..f0ec66bdb5 --- /dev/null +++ b/Examples/UIExplorer/AppDelegate.h @@ -0,0 +1,10 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import + +@interface AppDelegate : UIResponder + +@property (nonatomic, strong) UIWindow *window; + +@end + diff --git a/Examples/UIExplorer/AppDelegate.m b/Examples/UIExplorer/AppDelegate.m new file mode 100644 index 0000000000..a3fdbd6297 --- /dev/null +++ b/Examples/UIExplorer/AppDelegate.m @@ -0,0 +1,44 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "AppDelegate.h" + +#import "RCTRootView.h" + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions +{ + NSURL *jsCodeLocation; + RCTRootView *rootView = [[RCTRootView alloc] init]; + + // Loading JavaScript code - uncomment the one you want. + + // OPTION 1 + // Load from development server. Start the server from the repository root: + // + // $ npm start + // + // To run on device, change `localhost` to the IP address of your computer, and make sure your computer and + // iOS device are on the same Wi-Fi network. + jsCodeLocation = [NSURL URLWithString:@"http://localhost:8081/Examples/UIExplorer/UIExplorerApp.includeRequire.runModule.bundle"]; + + // OPTION 2 + // Load from pre-bundled file on disk. To re-generate the static bundle, run + // + // $ curl http://localhost:8081/Examples/UIExplorer/UIExplorerApp.includeRequire.runModule.bundle -o main.jsbundle + // + // and uncomment the next following line + // jsCodeLocation = [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; + + rootView.scriptURL = jsCodeLocation; + rootView.moduleName = @"UIExplorerApp"; + + self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; + UIViewController *rootViewController = [[UIViewController alloc] init]; + rootViewController.view = rootView; + self.window.rootViewController = rootViewController; + [self.window makeKeyAndVisible]; + return YES; +} + +@end diff --git a/Examples/UIExplorer/Base.lproj/LaunchScreen.xib b/Examples/UIExplorer/Base.lproj/LaunchScreen.xib new file mode 100644 index 0000000000..3b7dcb4a08 --- /dev/null +++ b/Examples/UIExplorer/Base.lproj/LaunchScreen.xib @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Examples/UIExplorer/ExpandingTextExample.js b/Examples/UIExplorer/ExpandingTextExample.js new file mode 100644 index 0000000000..74d6eb3005 --- /dev/null +++ b/Examples/UIExplorer/ExpandingTextExample.js @@ -0,0 +1,54 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ExpandingTextExample + */ +'use strict'; + +var React = require('react-native'); +var { + ExpandingText +} = React; + +var LOREM = 'Lorem ipsum dolor sit amet, mea adipisci inimicus ex, paulo essent bonorum et ius, rebum deserunt mediocritatem ius ei.'; + +exports.title = ''; +exports.description = 'Base component for rendering text that is truncated and can be expanded upon tap.'; +exports.examples = [ +{ + title: 'Expanding text (truncLength=20)', + description: 'Setting the truncLength prop will cause the text to truncate to that character length', + render: function() { + return ; + } +}, { + title: 'Expanding text (truncLength=80)', + description: 'The higher the truncLength the more characters that will be shown by default', + render: function() { + return ; + } +}, { + title: 'Expanding text with custom style', + description: 'You can style the text within the ExpandingText component', + render: function() { + return ( + + ); + } +}, { + title: 'See More button with custom style' , + description: 'You can also style just the See More button', + render: function() { + return ( + + ); + } +}]; diff --git a/Examples/UIExplorer/ImageCapInsetsExample.js b/Examples/UIExplorer/ImageCapInsetsExample.js new file mode 100644 index 0000000000..20fea43c71 --- /dev/null +++ b/Examples/UIExplorer/ImageCapInsetsExample.js @@ -0,0 +1,66 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ImageCapInsetsExample + */ +'use strict'; + +var React = require('react-native'); +var { + Image, + StyleSheet, + Text, + View, + ix, +} = React; + +var ImageCapInsetsExample = React.createClass({ + render: function() { + return ( + + + + capInsets: none + + + + + + capInsets: 15 + + + + + ); + } +}); + +var styles = StyleSheet.create({ + background: { + backgroundColor: '#F6F6F6', + justifyContent: 'center', + alignItems: 'center', + }, + horizontal: { + flexDirection: 'row', + }, + storyBackground: { + width: 250, + height: 150, + borderWidth: 1, + resizeMode: Image.resizeMode.stretch, + }, + text: { + fontSize: 13.5, + } +}); + +module.exports = ImageCapInsetsExample; diff --git a/Examples/UIExplorer/ImageExample.js b/Examples/UIExplorer/ImageExample.js new file mode 100644 index 0000000000..00a8b39e67 --- /dev/null +++ b/Examples/UIExplorer/ImageExample.js @@ -0,0 +1,305 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ImageExample + */ +'use strict'; + +var React = require('react-native'); +var { + Image, + StyleSheet, + Text, + View, + ix, +} = React; + +var ImageCapInsetsExample = require('./ImageCapInsetsExample'); + +exports.framework = 'React'; +exports.title = ''; +exports.description = 'Base component for displaying different types of images.'; + +exports.examples = [ + { + title: 'Plain Network Image', + description: 'If the `source` prop `uri` property is prefixed with ' + + '"http", then it will be downloaded from the network.', + render: function() { + return ( + + ); + }, + }, + { + title: 'Plain Static Image', + description: 'Static assets must be referenced with the `ix` wrapper and ' + + 'located in the app bundle.', + render: function() { + return ( + + + + + + + ); + }, + }, + { + title: 'Border Color', + render: function() { + return ( + + + + ); + }, + }, + { + title: 'Border Width', + render: function() { + return ( + + + + ); + }, + }, + { + title: 'Border Radius', + render: function() { + return ( + + + + + ); + }, + }, + { + title: 'Background Color', + render: function() { + return ( + + + + + + + ); + }, + }, + { + title: 'Opacity', + render: function() { + return ( + + + + + + + + + ); + }, + }, + { + title: 'Nesting', + render: function() { + return ( + + + React + + + ); + }, + }, + { + title: 'Tint Color', + description: 'The `tintColor` style prop changes all the non-alpha ' + + 'pixels to the tint color.', + render: function() { + return ( + + + + + + + ); + }, + }, + { + title: 'Resize Mode', + description: 'The `resizeMode` style prop controls how the image is ' + + 'rendered within the frame.', + render: function() { + return ( + + + + Contain + + + + + + Cover + + + + + + Stretch + + + + + ); + }, + }, + { + title: 'Cap Insets', + description: + 'When the image is resized, the corners of the size specified ' + + 'by capInsets will stay a fixed size, but the center content and ' + + 'borders of the image will be stretched. This is useful for creating ' + + 'resizable rounded buttons, shadows, and other resizable assets.', + render: function() { + return ; + }, + }, +]; + +var fullImage = {uri: 'http://facebook.github.io/react/img/logo_og.png'}; +var smallImage = {uri: 'http://facebook.github.io/react/img/logo_small.png'}; + +var styles = StyleSheet.create({ + base: { + width: 38, + height: 38, + }, + leftMargin: { + marginLeft: 10, + }, + background: { + backgroundColor: '#222222' + }, + nestedText: { + marginLeft: 12, + marginTop: 20, + backgroundColor: 'transparent', + color: 'white' + }, + resizeMode: { + width: 90, + height: 60, + borderWidth: 0.5, + borderColor: 'black' + }, + resizeModeText: { + fontSize: 11, + marginBottom: 3, + }, + icon: { + width: 15, + height: 15, + }, + horizontal: { + flexDirection: 'row', + } +}); diff --git a/Examples/UIExplorer/Images.xcassets/AppIcon.appiconset/Contents.json b/Examples/UIExplorer/Images.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..413d60e76d --- /dev/null +++ b/Examples/UIExplorer/Images.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,44 @@ +{ + "images" : [ + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "uie_icon@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "uie_icon@2x-1.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "uie_icon@2x-2.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "uie_icon@2x-3.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "uie_icon@2x-5.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "uie_icon@2x-4.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Examples/UIExplorer/Images.xcassets/AppIcon.appiconset/uie_icon@2x-1.png b/Examples/UIExplorer/Images.xcassets/AppIcon.appiconset/uie_icon@2x-1.png new file mode 100644 index 0000000000..08a42699da Binary files /dev/null and b/Examples/UIExplorer/Images.xcassets/AppIcon.appiconset/uie_icon@2x-1.png differ diff --git a/Examples/UIExplorer/Images.xcassets/AppIcon.appiconset/uie_icon@2x-2.png b/Examples/UIExplorer/Images.xcassets/AppIcon.appiconset/uie_icon@2x-2.png new file mode 100644 index 0000000000..08a42699da Binary files /dev/null and b/Examples/UIExplorer/Images.xcassets/AppIcon.appiconset/uie_icon@2x-2.png differ diff --git a/Examples/UIExplorer/Images.xcassets/AppIcon.appiconset/uie_icon@2x-3.png b/Examples/UIExplorer/Images.xcassets/AppIcon.appiconset/uie_icon@2x-3.png new file mode 100644 index 0000000000..08a42699da Binary files /dev/null and b/Examples/UIExplorer/Images.xcassets/AppIcon.appiconset/uie_icon@2x-3.png differ diff --git a/Examples/UIExplorer/Images.xcassets/AppIcon.appiconset/uie_icon@2x-4.png b/Examples/UIExplorer/Images.xcassets/AppIcon.appiconset/uie_icon@2x-4.png new file mode 100644 index 0000000000..08a42699da Binary files /dev/null and b/Examples/UIExplorer/Images.xcassets/AppIcon.appiconset/uie_icon@2x-4.png differ diff --git a/Examples/UIExplorer/Images.xcassets/AppIcon.appiconset/uie_icon@2x-5.png b/Examples/UIExplorer/Images.xcassets/AppIcon.appiconset/uie_icon@2x-5.png new file mode 100644 index 0000000000..08a42699da Binary files /dev/null and b/Examples/UIExplorer/Images.xcassets/AppIcon.appiconset/uie_icon@2x-5.png differ diff --git a/Examples/UIExplorer/Images.xcassets/AppIcon.appiconset/uie_icon@2x.png b/Examples/UIExplorer/Images.xcassets/AppIcon.appiconset/uie_icon@2x.png new file mode 100644 index 0000000000..08a42699da Binary files /dev/null and b/Examples/UIExplorer/Images.xcassets/AppIcon.appiconset/uie_icon@2x.png differ diff --git a/Examples/UIExplorer/Images.xcassets/story-background.imageset/Contents.json b/Examples/UIExplorer/Images.xcassets/story-background.imageset/Contents.json new file mode 100644 index 0000000000..65917726fa --- /dev/null +++ b/Examples/UIExplorer/Images.xcassets/story-background.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x", + "filename" : "story-background.png" + }, + { + "idiom" : "universal", + "scale" : "2x", + "filename" : "story-background@2x.png" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Examples/UIExplorer/Images.xcassets/story-background.imageset/story-background.png b/Examples/UIExplorer/Images.xcassets/story-background.imageset/story-background.png new file mode 100644 index 0000000000..cbb77db62d Binary files /dev/null and b/Examples/UIExplorer/Images.xcassets/story-background.imageset/story-background.png differ diff --git a/Examples/UIExplorer/Images.xcassets/story-background.imageset/story-background@2x.png b/Examples/UIExplorer/Images.xcassets/story-background.imageset/story-background@2x.png new file mode 100644 index 0000000000..fc082986c4 Binary files /dev/null and b/Examples/UIExplorer/Images.xcassets/story-background.imageset/story-background@2x.png differ diff --git a/Examples/UIExplorer/Images.xcassets/uie_comment_highlighted.imageset/Contents.json b/Examples/UIExplorer/Images.xcassets/uie_comment_highlighted.imageset/Contents.json new file mode 100644 index 0000000000..e1ccff627e --- /dev/null +++ b/Examples/UIExplorer/Images.xcassets/uie_comment_highlighted.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x", + "filename" : "uie_comment_highlighted@2x.png" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Examples/UIExplorer/Images.xcassets/uie_comment_highlighted.imageset/uie_comment_highlighted@2x.png b/Examples/UIExplorer/Images.xcassets/uie_comment_highlighted.imageset/uie_comment_highlighted@2x.png new file mode 100644 index 0000000000..b33726757e Binary files /dev/null and b/Examples/UIExplorer/Images.xcassets/uie_comment_highlighted.imageset/uie_comment_highlighted@2x.png differ diff --git a/Examples/UIExplorer/Images.xcassets/uie_comment_normal.imageset/Contents.json b/Examples/UIExplorer/Images.xcassets/uie_comment_normal.imageset/Contents.json new file mode 100644 index 0000000000..6f75231d5e --- /dev/null +++ b/Examples/UIExplorer/Images.xcassets/uie_comment_normal.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x", + "filename" : "uie_comment_normal@2x.png" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Examples/UIExplorer/Images.xcassets/uie_comment_normal.imageset/uie_comment_normal@2x.png b/Examples/UIExplorer/Images.xcassets/uie_comment_normal.imageset/uie_comment_normal@2x.png new file mode 100644 index 0000000000..6491689fbb Binary files /dev/null and b/Examples/UIExplorer/Images.xcassets/uie_comment_normal.imageset/uie_comment_normal@2x.png differ diff --git a/Examples/UIExplorer/Images.xcassets/uie_thumb_normal.imageset/Contents.json b/Examples/UIExplorer/Images.xcassets/uie_thumb_normal.imageset/Contents.json new file mode 100644 index 0000000000..06a7acabf5 --- /dev/null +++ b/Examples/UIExplorer/Images.xcassets/uie_thumb_normal.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x", + "filename" : "uie_thumb_normal@2x.png" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Examples/UIExplorer/Images.xcassets/uie_thumb_normal.imageset/uie_thumb_normal@2x.png b/Examples/UIExplorer/Images.xcassets/uie_thumb_normal.imageset/uie_thumb_normal@2x.png new file mode 100644 index 0000000000..72683dfac1 Binary files /dev/null and b/Examples/UIExplorer/Images.xcassets/uie_thumb_normal.imageset/uie_thumb_normal@2x.png differ diff --git a/Examples/UIExplorer/Images.xcassets/uie_thumb_selected.imageset/Contents.json b/Examples/UIExplorer/Images.xcassets/uie_thumb_selected.imageset/Contents.json new file mode 100644 index 0000000000..a00a3dc54d --- /dev/null +++ b/Examples/UIExplorer/Images.xcassets/uie_thumb_selected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x", + "filename" : "uie_thumb_selected@2x.png" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Examples/UIExplorer/Images.xcassets/uie_thumb_selected.imageset/uie_thumb_selected@2x.png b/Examples/UIExplorer/Images.xcassets/uie_thumb_selected.imageset/uie_thumb_selected@2x.png new file mode 100644 index 0000000000..79eb69cf92 Binary files /dev/null and b/Examples/UIExplorer/Images.xcassets/uie_thumb_selected.imageset/uie_thumb_selected@2x.png differ diff --git a/Examples/UIExplorer/Info.plist b/Examples/UIExplorer/Info.plist new file mode 100644 index 0000000000..9a7ca7e3cc --- /dev/null +++ b/Examples/UIExplorer/Info.plist @@ -0,0 +1,40 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + com.facebook.$(PRODUCT_NAME:rfc1034identifier) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/Examples/UIExplorer/LayoutExample.js b/Examples/UIExplorer/LayoutExample.js new file mode 100644 index 0000000000..746ef0ac6a --- /dev/null +++ b/Examples/UIExplorer/LayoutExample.js @@ -0,0 +1,145 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule LayoutExample + */ +'use strict'; + +var React = require('react-native'); +var { + StyleSheet, + Text, + View, +} = React; + +var UIExplorerBlock = require('./UIExplorerBlock'); +var UIExplorerPage = require('./UIExplorerPage'); + +var Circle = React.createClass({ + render: function() { + var size = this.props.size || 20; + return ( + + ); + } +}); + +var CircleBlock = React.createClass({ + render: function() { + var circleStyle = { + flexDirection: 'row', + backgroundColor: '#f6f7f8', + borderWidth: 0.5, + borderColor: '#d6d7da', + marginBottom: 2, + }; + return ( + + {this.props.children} + + ); + } +}); + +var LayoutExample = React.createClass({ + statics: { + title: 'Layout - Flexbox', + description: 'Examples of using the flexbox API to layout views.' + }, + render: function() { + return ( + + + row + + + + column + + + + + {'top: 15, left: 160'} + + + + + flex-start + + + + center + + + + flex-end + + + + space-between + + + + space-around + + + + + + flex-start + + + + + + + + + center + + + + + + + + + flex-end + + + + + + + + + + + + {'oooooooooooooooo'.split('').map((char, i) => )} + + + + ); + } +}); + +var styles = StyleSheet.create({ + overlay: { + backgroundColor: '#aaccff', + borderRadius: 10, + borderWidth: 0.5, + opacity: 0.5, + padding: 5, + }, +}); + +module.exports = LayoutExample; diff --git a/Examples/UIExplorer/ListViewPagingExample.js b/Examples/UIExplorer/ListViewPagingExample.js new file mode 100644 index 0000000000..4818da9981 --- /dev/null +++ b/Examples/UIExplorer/ListViewPagingExample.js @@ -0,0 +1,250 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ListViewPagingExample + * @flow + */ +'use strict'; + +var React = require('react-native'); +var { + Image, + LayoutAnimation, + ListView, + ListViewDataSource, + StyleSheet, + Text, + TouchableOpacity, + View, +} = React; + +var PAGE_SIZE = 4; +var THUMB_URLS = ['https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-ash3/t39.1997/p128x128/851549_767334479959628_274486868_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851561_767334496626293_1958532586_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-ash3/t39.1997/p128x128/851579_767334503292959_179092627_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851589_767334513292958_1747022277_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851563_767334559959620_1193692107_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851593_767334566626286_1953955109_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851591_767334523292957_797560749_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851567_767334529959623_843148472_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851548_767334489959627_794462220_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851575_767334539959622_441598241_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-ash3/t39.1997/p128x128/851573_767334549959621_534583464_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851583_767334573292952_1519550680_n.png']; +var NUM_SECTIONS = 100; +var NUM_ROWS_PER_SECTION = 10; + +var Thumb = React.createClass({ + getInitialState: function() { + return {thumbIndex: this._getThumbIdx(), dir: 'row'}; + }, + _getThumbIdx: function() { + return Math.floor(Math.random() * THUMB_URLS.length); + }, + _onPressThumb: function() { + var config = layoutAnimationConfigs[this.state.thumbIndex % layoutAnimationConfigs.length]; + LayoutAnimation.configureNext(config); + this.setState({ + thumbIndex: this._getThumbIdx(), + dir: this.state.dir === 'row' ? 'column' : 'row', + }); + }, + render: function() { + return ( + + + + + + {this.state.dir === 'column' ? + + Oooo, look at this new text! So awesome it may just be crazy. + Let me keep typing here so it wraps at least one line. + : + + } + + + ); + } +}); + +var ListViewPagingExample = React.createClass({ + statics: { + title: ' - Paging', + description: 'Floating headers & layout animations.' + }, + + getInitialState: function() { + var getSectionData = (dataBlob, sectionID) => { + return dataBlob[sectionID]; + }; + var getRowData = (dataBlob, sectionID, rowID) => { + return dataBlob[rowID]; + }; + + var dataSource = new ListViewDataSource({ + getRowData: getRowData, + getSectionHeaderData: getSectionData, + rowHasChanged: (row1, row2) => row1 !== row2, + sectionHeaderHasChanged: (s1, s2) => s1 !== s2, + }); + + var dataBlob = {}; + var sectionIDs = []; + var rowIDs = []; + for (var ii = 0; ii < NUM_SECTIONS; ii++) { + var sectionName = 'Section ' + ii; + sectionIDs.push(sectionName); + dataBlob[sectionName] = sectionName; + rowIDs[ii] = []; + + for (var jj = 0; jj < NUM_ROWS_PER_SECTION; jj++) { + var rowName = 'S' + ii + ', R' + jj; + rowIDs[ii].push(rowName); + dataBlob[rowName] = rowName; + } + } + return { + dataSource: dataSource.cloneWithRowsAndSections(dataBlob, sectionIDs, rowIDs), + headerPressCount: 0, + }; + }, + + renderRow: function(rowData, sectionID, rowID) { + return (); + }, + + renderSectionHeader: function(sectionData, sectionID) { + return ( + + + {sectionData} + + + ); + }, + + renderHeader: function() { + var headerLikeText = this.state.headerPressCount % 2 ? + 1 Like : + null; + return ( + + + {headerLikeText} + + + Table Header (click me) + + + + + ); + }, + + renderFooter: function() { + return ( + + console.log('Footer!')} style={styles.text}> + Table Footer + + + ); + }, + + render: function() { + return ( + console.log({visibleRows, changedRows})} + renderHeader={this.renderHeader} + renderFooter={this.renderFooter} + renderSectionHeader={this.renderSectionHeader} + renderRow={this.renderRow} + initialListSize={10} + pageSize={PAGE_SIZE} + scrollRenderAheadDistance={2000} + /> + ); + }, + + _onPressHeader: function() { + var config = layoutAnimationConfigs[Math.floor(this.state.headerPressCount / 2) % layoutAnimationConfigs.length]; + LayoutAnimation.configureNext(config); + this.setState({headerPressCount: this.state.headerPressCount + 1}); + }, + +}); + +var styles = StyleSheet.create({ + listview: { + backgroundColor: '#B0C4DE', + }, + header: { + height: 40, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#3B5998', + flexDirection: 'row', + }, + text: { + color: 'white', + paddingHorizontal: 8, + }, + rowText: { + color: '#888888', + }, + thumbText: { + fontSize: 20, + color: '#888888', + }, + buttonContents: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + marginHorizontal: 5, + marginVertical: 3, + padding: 5, + backgroundColor: '#EAEAEA', + borderRadius: 3, + paddingVertical: 10, + }, + img: { + width: 64, + height: 64, + marginHorizontal: 10, + }, + section: { + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'flex-start', + padding: 6, + backgroundColor: '#5890ff', + }, +}); + +var animations = { + layout: { + spring: { + duration: 0.75, + create: { + duration: 0.3, + type: LayoutAnimation.Types.easeInEaseOut, + property: LayoutAnimation.Properties.opacity, + }, + update: { + type: LayoutAnimation.Types.spring, + springDamping: 0.4, + }, + }, + easeInEaseOut: { + duration: 0.3, + create: { + type: LayoutAnimation.Types.easeInEaseOut, + property: LayoutAnimation.Properties.scaleXY, + }, + update: { + delay: 0.1, + type: LayoutAnimation.Types.easeInEaseOut, + }, + }, + }, +}; + +var layoutAnimationConfigs = [ + animations.layout.spring, + animations.layout.easeInEaseOut, +]; + +module.exports = ListViewPagingExample; diff --git a/Examples/UIExplorer/ListViewSimpleExample.js b/Examples/UIExplorer/ListViewSimpleExample.js new file mode 100644 index 0000000000..db61308433 --- /dev/null +++ b/Examples/UIExplorer/ListViewSimpleExample.js @@ -0,0 +1,121 @@ +/** +* Copyright 2004-present Facebook. All Rights Reserved. +* +* @provides ListViewSimpleExample +*/ +'use strict'; + +var React = require('react-native'); +var { + Image, + ListView, + ListViewDataSource, + TouchableHighlight, + StyleSheet, + Text, + View, +} = React; + +var UIExplorerPage = require('./UIExplorerPage'); + +var ListViewSimpleExample = React.createClass({ + statics: { + title: ' - Simple', + description: 'Performant, scrollable list of data.' + }, + + getInitialState: function() { + var ds = new ListViewDataSource({rowHasChanged: (r1, r2) => r1 !== r2}); + return { + dataSource: ds.cloneWithRows(this._genRows({})), + }; + }, + + componentWillMount: function() { + this._pressData = {}; + }, + + render: function() { + return ( + - Simple'} + noSpacer={true} + noScroll={true}> + + + ); + }, + + _renderRow: function(rowData, sectionID, rowID) { + var rowHash = Math.abs(hashCode(rowData)); + var imgSource = { + uri: THUMB_URLS[rowHash % THUMB_URLS.length], + }; + return ( + this._pressRow(rowID)}> + + + + + {rowData + ' - ' + LOREM_IPSUM.substr(0, rowHash % 301 + 10)} + + + + + + ); + }, + + _genRows: function(pressData) { + var dataBlob = []; + for (var ii = 0; ii < 100; ii++) { + var pressedText = pressData[ii] ? ' (pressed)' : ''; + dataBlob.push('Row ' + ii + pressedText); + } + return dataBlob; + }, + + _pressRow: function(rowID) { + this._pressData[rowID] = !this._pressData[rowID]; + this.setState({dataSource: this.state.dataSource.cloneWithRows( + this._genRows(this._pressData) + )}); + }, +}); + +var THUMB_URLS = ['https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-ash3/t39.1997/p128x128/851549_767334479959628_274486868_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851561_767334496626293_1958532586_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-ash3/t39.1997/p128x128/851579_767334503292959_179092627_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851589_767334513292958_1747022277_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851563_767334559959620_1193692107_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851593_767334566626286_1953955109_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851591_767334523292957_797560749_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851567_767334529959623_843148472_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851548_767334489959627_794462220_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851575_767334539959622_441598241_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-ash3/t39.1997/p128x128/851573_767334549959621_534583464_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851583_767334573292952_1519550680_n.png']; +var LOREM_IPSUM = 'Lorem ipsum dolor sit amet, ius ad pertinax oportere accommodare, an vix civibus corrumpit referrentur. Te nam case ludus inciderint, te mea facilisi adipiscing. Sea id integre luptatum. In tota sale consequuntur nec. Erat ocurreret mei ei. Eu paulo sapientem vulputate est, vel an accusam intellegam interesset. Nam eu stet pericula reprimique, ea vim illud modus, putant invidunt reprehendunt ne qui.'; + +/* eslint no-bitwise: 0 */ +var hashCode = function(str) { + var hash = 15; + for (var ii = str.length - 1; ii >= 0; ii--) { + hash = ((hash << 5) - hash) + str.charCodeAt(ii); + } + return hash; +}; + +var styles = StyleSheet.create({ + row: { + flexDirection: 'row', + justifyContent: 'center', + padding: 10, + backgroundColor: '#F6F6F6', + }, + separator: { + height: 1, + backgroundColor: '#CCCCCC', + }, + thumb: { + width: 64, + height: 64, + }, + text: { + flex: 1, + }, +}); + +module.exports = ListViewSimpleExample; diff --git a/Examples/UIExplorer/NavigatorIOSExample.js b/Examples/UIExplorer/NavigatorIOSExample.js new file mode 100644 index 0000000000..fa998528f0 --- /dev/null +++ b/Examples/UIExplorer/NavigatorIOSExample.js @@ -0,0 +1,216 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule NavigatorIOSExample + */ +'use strict'; + +var React = require('react-native/addons'); +var ViewExample = require('./ViewExample'); +var { + PixelRatio, + ScrollView, + StyleSheet, + Text, + TouchableHighlight, + View, +} = React; + +var EmptyPage = React.createClass({ + + render: function() { + return ( + + + {this.props.text} + + + ); + }, + +}); + +var NavigatorIOSExample = React.createClass({ + + statics: { + title: '', + description: 'iOS navigation capabilities', + }, + + render: function() { + var recurseTitle = 'Recurse Navigation'; + if (!this.props.topExampleRoute) { + recurseTitle += ' - more examples here'; + } + return ( + + + + + + See <UIExplorerApp> for top-level usage. + + + + + + + + {this._renderRow(recurseTitle, () => { + this.props.navigator.push({ + title: NavigatorIOSExample.title, + component: NavigatorIOSExample, + backButtonTitle: 'Custom Back', + passProps: {topExampleRoute: this.props.topExampleRoute || this.props.route}, + }); + })} + {this._renderRow('Push View Example', () => { + this.props.navigator.push({ + title: 'Very Long Custom View Example Title', + component: ViewExample, + }); + })} + {this._renderRow('Custom Right Button', () => { + this.props.navigator.push({ + title: NavigatorIOSExample.title, + component: EmptyPage, + rightButtonTitle: 'Cancel', + onRightButtonPress: () => this.props.navigator.pop(), + passProps: { + text: 'This page has a right button in the nav bar', + } + }); + })} + {this._renderRow('Pop', () => { + this.props.navigator.pop(); + })} + {this._renderRow('Pop to top', () => { + this.props.navigator.popToTop(); + })} + {this._renderRow('Replace here', () => { + var prevRoute = this.props.route; + this.props.navigator.replace({ + title: 'New Navigation', + component: EmptyPage, + rightButtonTitle: 'Undo', + onRightButtonPress: () => this.props.navigator.replace(prevRoute), + passProps: { + text: 'The component is replaced, but there is currently no ' + + 'way to change the right button or title of the current route', + } + }); + })} + {this._renderReplacePrevious()} + {this._renderReplacePreviousAndPop()} + {this._renderPopToTopNavExample()} + + + + ); + }, + + _renderPopToTopNavExample: function() { + if (!this.props.topExampleRoute) { + return null; + } + return this._renderRow('Pop to top NavigatorIOSExample', () => { + this.props.navigator.popToRoute(this.props.topExampleRoute); + }); + }, + + _renderReplacePrevious: function() { + if (!this.props.topExampleRoute) { + // this is to avoid replacing the UIExplorerList at the top of the stack + return null; + } + return this._renderRow('Replace previous', () => { + this.props.navigator.replacePrevious({ + title: 'Replaced', + component: EmptyPage, + passProps: { + text: 'This is a replaced "previous" page', + }, + wrapperStyle: styles.customWrapperStyle, + }); + }); + }, + + _renderReplacePreviousAndPop: function() { + if (!this.props.topExampleRoute) { + // this is to avoid replacing the UIExplorerList at the top of the stack + return null; + } + return this._renderRow('Replace previous and pop', () => { + this.props.navigator.replacePreviousAndPop({ + title: 'Replaced and Popped', + component: EmptyPage, + passProps: { + text: 'This is a replaced "previous" page', + }, + wrapperStyle: styles.customWrapperStyle, + }); + }); + }, + + _renderRow: function(title, onPress) { + return ( + + + + + {title} + + + + + + ); + }, +}); + +var styles = StyleSheet.create({ + customWrapperStyle: { + backgroundColor: '#bbdddd', + }, + emptyPage: { + flex: 1, + paddingTop: 64, + }, + emptyPageText: { + margin: 10, + }, + list: { + backgroundColor: '#eeeeee', + marginTop: 10, + }, + group: { + backgroundColor: 'white', + }, + groupSpace: { + height: 15, + }, + line: { + backgroundColor: '#bbbbbb', + height: 1 / PixelRatio.get(), + }, + row: { + backgroundColor: 'white', + justifyContent: 'center', + paddingHorizontal: 15, + paddingVertical: 15, + }, + separator: { + height: 1 / PixelRatio.get(), + backgroundColor: '#bbbbbb', + marginLeft: 15, + }, + rowNote: { + fontSize: 17, + }, + rowText: { + fontSize: 17, + fontWeight: 'bold', + }, +}); + +module.exports = NavigatorIOSExample; diff --git a/Examples/UIExplorer/PointerEventsExample.js b/Examples/UIExplorer/PointerEventsExample.js new file mode 100644 index 0000000000..0bc6c2b13d --- /dev/null +++ b/Examples/UIExplorer/PointerEventsExample.js @@ -0,0 +1,239 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule PointerEventsExample + */ +'use strict'; + +var React = require('react-native'); +var { + StyleSheet, + Text, + View, +} = React; + +var ExampleBox = React.createClass({ + getInitialState: function() { + return { + log: [], + }; + }, + handleLog: function(msg) { + this.state.log = this.state.log.concat([msg]); + }, + flushReactChanges: function() { + this.forceUpdate(); + }, + /** + * Capture phase of bubbling to append separator before any of the bubbling + * happens. + */ + handleTouchCapture: function() { + this.state.log = this.state.log.concat(['---']); + }, + render: function() { + return ( + + + + + + + {this.state.log.join('\n')} + + + + ); + } +}); + + +var NoneExample = React.createClass({ + render: function() { + return ( + this.props.onLog('A unspecified touched')} + style={styles.box}> + + A: unspecified + + this.props.onLog('B none touched')} + style={[styles.box, styles.boxPassedThrough]}> + + B: none + + this.props.onLog('C unspecified touched')} + style={[styles.box, styles.boxPassedThrough]}> + + C: unspecified + + + + + ); + } +}); + +/** + * Special demo text that makes itself untouchable so that it doesn't destroy + * the experiment and confuse the output. + */ +var DemoText = React.createClass({ + render: function() { + return ( + + + {this.props.children} + + + ); + } +}); + +var BoxNoneExample = React.createClass({ + render: function() { + return ( + this.props.onLog('A unspecified touched')} + style={styles.box}> + + A: unspecified + + this.props.onLog('B boxNone touched')} + style={[styles.box, styles.boxPassedThrough]}> + + B: boxNone + + this.props.onLog('C unspecified touched')} + style={styles.box}> + + C: unspecified + + + this.props.onLog('C explicitly unspecified touched')} + style={[styles.box]}> + + C: explicitly unspecified + + + + + ); + } +}); + +var BoxOnlyExample = React.createClass({ + render: function() { + return ( + this.props.onLog('A unspecified touched')} + style={styles.box}> + + A: unspecified + + this.props.onLog('B boxOnly touched')} + style={styles.box}> + + B: boxOnly + + this.props.onLog('C unspecified touched')} + style={[styles.box, styles.boxPassedThrough]}> + + C: unspecified + + + this.props.onLog('C explicitly unspecified touched')} + style={[styles.box, styles.boxPassedThrough]}> + + C: explicitly unspecified + + + + + ); + } +}); + +var exampleClasses = [ + { + Component: NoneExample, + title: '`none`', + description: '`none` causes touch events on the container and its child components to pass through to the parent container.', + }, + { + Component: BoxNoneExample, + title: '`boxNone`', + description: '`boxNone` causes touch events on the container to pass through and will only detect touch events on its child components.', + }, + { + Component: BoxOnlyExample, + title: '`boxOnly`', + description: '`boxOnly` causes touch events on the container\'s child components to pass through and will only detect touch events on the container itself.', + } +]; + +var infoToExample = (info) => { + return { + title: info.title, + description: info.description, + render: function() { + return ; + }, + }; +}; + +var styles = StyleSheet.create({ + text: { + fontSize: 10, + color: '#5577cc', + }, + textPassedThrough: { + color: '#88aadd', + }, + box: { + backgroundColor: '#aaccff', + borderWidth: 1, + borderColor: '#7799cc', + padding: 10, + margin: 5, + }, + boxPassedThrough: { + borderColor: '#99bbee', + }, + logText: { + fontSize: 9, + }, + logBox: { + padding: 20, + margin: 10, + borderWidth: 0.5, + borderColor: '#f0f0f0', + backgroundColor: '#f9f9f9', + }, + bottomSpacer: { + marginBottom: 100, + }, +}); + +exports.framework = 'React'; +exports.title = 'Pointer Events'; +exports.description = '`pointerEvents` is a prop of View that gives control ' + + 'of how touches should be handled.'; +exports.examples = exampleClasses.map(infoToExample); diff --git a/Examples/UIExplorer/ScrollViewExample.js b/Examples/UIExplorer/ScrollViewExample.js new file mode 100644 index 0000000000..68df23b1d9 --- /dev/null +++ b/Examples/UIExplorer/ScrollViewExample.js @@ -0,0 +1,102 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ScrollViewExample + */ +'use strict'; + +var React = require('react-native'); +var { + ScrollView, + StyleSheet, + View, + Image +} = React; + +exports.title = ''; +exports.description = 'Component that enables scrolling through child components'; +exports.examples = [ +{ + title: '', + description: 'To make content scrollable, wrap it within a component', + render: function() { + return ( + { console.log('onScroll!'); }} + throttleScrollCallbackMS={200} + contentInset={{top: -50}} + style={styles.scrollView}> + {THUMBS.map(createThumbRow)} + + ); + } +}, { + title: ' (horizontal = true)', + description: 'You can display \'s child components horizontally rather than vertically', + render: function() { + return ( + + {THUMBS.map(createThumbRow)} + + ); + } +}]; + +var Thumb = React.createClass({ + shouldComponentUpdate: function(nextProps, nextState) { + return false; + }, + render: function() { + return ( + + + + ); + } +}); + +var THUMBS = ['https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-ash3/t39.1997/p128x128/851549_767334479959628_274486868_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851561_767334496626293_1958532586_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-ash3/t39.1997/p128x128/851579_767334503292959_179092627_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851589_767334513292958_1747022277_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851563_767334559959620_1193692107_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851593_767334566626286_1953955109_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851591_767334523292957_797560749_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851567_767334529959623_843148472_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851548_767334489959627_794462220_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851575_767334539959622_441598241_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-ash3/t39.1997/p128x128/851573_767334549959621_534583464_n.png', 'https://fbcdn-dragon-a.akamaihd.net/hphotos-ak-prn1/t39.1997/p128x128/851583_767334573292952_1519550680_n.png']; +THUMBS = THUMBS.concat(THUMBS); // double length of THUMBS +var createThumbRow = (uri, i) => ; + +var styles = StyleSheet.create({ + scrollView: { + backgroundColor: '#6A85B1', + height: 300, + }, + horizontalScrollView: { + height: 120, + }, + containerPage: { + height: 50, + width: 50, + backgroundColor: '#527FE4', + padding: 5, + }, + text: { + fontSize: 20, + color: '#888888', + left: 80, + top: 20, + height: 40, + }, + button: { + margin: 7, + padding: 5, + alignItems: 'center', + backgroundColor: '#eaeaea', + borderRadius: 3, + }, + buttonContents: { + flexDirection: 'row', + width: 64, + height: 64, + }, + img: { + width: 64, + height: 64, + } +}); diff --git a/Examples/UIExplorer/StatusBarIOSExample.js b/Examples/UIExplorer/StatusBarIOSExample.js new file mode 100644 index 0000000000..1ee83419a7 --- /dev/null +++ b/Examples/UIExplorer/StatusBarIOSExample.js @@ -0,0 +1,87 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule StatusBarIOSExample + */ +'use strict'; + +var React = require('react-native'); +var { + StyleSheet, + View, + Text, + TouchableHighlight, + StatusBarIOS, +} = React; + +exports.framework = 'React'; +exports.title = 'StatusBarIOS'; +exports.description = 'Module for controlling iOS status bar'; +exports.examples = [{ + title: 'Status Bar Style', + render() { + return ( + + {Object.keys(StatusBarIOS.Style).map((key) => + StatusBarIOS.setStyle(StatusBarIOS.Style[key])}> + + setStyle(StatusBarIOS.Style.{key}) + + + )} + + ); + }, +}, { + title: 'Status Bar Style Animated', + render() { + return ( + + {Object.keys(StatusBarIOS.Style).map((key) => + StatusBarIOS.setStyle(StatusBarIOS.Style[key], true)}> + + setStyle(StatusBarIOS.Style.{key}, true) + + + )} + + ); + }, +}, { + title: 'Status Bar Hidden', + render() { + return ( + + {Object.keys(StatusBarIOS.Animation).map((key) => + + StatusBarIOS.setHidden(true, StatusBarIOS.Animation[key])}> + + setHidden(true, StatusBarIOS.Animation.{key}) + + + StatusBarIOS.setHidden(false, StatusBarIOS.Animation[key])}> + + setHidden(false, StatusBarIOS.Animation.{key}) + + + + )} + + ); + }, +}]; + +var styles = StyleSheet.create({ + wrapper: { + borderRadius: 5, + marginBottom: 5, + }, + button: { + backgroundColor: '#eeeeee', + padding: 10, + }, +}); diff --git a/Examples/UIExplorer/TextExample.ios.js b/Examples/UIExplorer/TextExample.ios.js new file mode 100644 index 0000000000..a0d5eba330 --- /dev/null +++ b/Examples/UIExplorer/TextExample.ios.js @@ -0,0 +1,279 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule TextExample + */ +'use strict'; + +var React = require('react-native'); +var { + StyleSheet, + Text, + View, +} = React; + +var Entity = React.createClass({ + render: function() { + return ( + + {this.props.children} + + ); + } +}); + +var AttributeToggler = React.createClass({ + getInitialState: function() { + return {fontWeight: 'bold', fontSize: 15}; + }, + increaseSize: function() { + this.setState({ + fontSize: this.state.fontSize + 1 + }); + }, + render: function() { + var curStyle = {fontSize: this.state.fontSize}; + return ( + + + Tap the controls below to change attributes. + + + See how it will even work on{' '} + + this nested text + + + {'>> Increase Size <<'} + + + + ); + } +}); + +exports.title = ''; +exports.description = 'Base component for rendering styled text.'; +exports.examples = [ +{ + title: 'Wrap', + render: function() { + return ( + + The text should wrap if it goes on multiple lines. See, this is going to + the next line. + + ); + }, +}, { + title: 'Font Family', + render: function() { + return ( + + + Cochin + + + Cochin bold + + + Helvetica + + + Helvetica bold + + + Verdana + + + Verdana bold + + + ); + }, +}, { + title: 'Font Size', + render: function() { + return ( + + + Size 23 + + + Size 8 + + + ); + }, +}, { + title: 'Color', + render: function() { + return ( + + + Red color + + + Blue color + + + ); + }, +}, { + title: 'Font Weight', + render: function() { + return ( + + Move fast and be bold + + ); + }, +}, { + title: 'Nested', + description: 'Nested text components will inherit the styles of their ' + + 'parents (only backgroundColor is inherited from non-Text parents). ' + + ' only supports other and raw text (strings) as children.', + render: function() { + return ( + + + (Normal text, + + (and bold + + (and tiny inherited bold blue) + + ) + + ) + + + Entity Name + + + ); + }, +}, { + title: 'Text Align', + render: function() { + return ( + + + left left left left left left left left left left left left left left left + + + center center center center center center center center center center center + + + right right right right right right right right right right right right right + + + ); + }, +}, { + title: 'Spaces', + render: function() { + return ( + + A {'generated'} {' '} {'string'} and some     spaces + + ); + }, +}, { + title: 'Line Height', + render: function() { + return ( + + + A lot of space between the lines of this long passage that should + wrap once. + + + ); + }, +}, { + title: 'Empty Text', + description: 'It\'s ok to have Text with zero or null children.', + render: function() { + return ( + + ); + }, +}, { + title: 'Toggling Attributes', + render: function() { + return ; + }, +}, { + title: 'backgroundColor attribute', + description: 'backgroundColor is inherited from all types of views.', + render: function() { + return ( + + + Yellow background inherited from View parent, + + {' '}red background, + + {' '}blue background, + + {' '}inherited blue background, + + {' '}nested green background. + + + + + + + ); + }, +}, { + title: 'containerBackgroundColor attribute', + render: function() { + return ( + + + + + + + Default containerBackgroundColor (inherited) + backgroundColor wash + + + {"containerBackgroundColor: 'transparent' + backgroundColor wash"} + + + ); + }, +}, { + title: 'numberOfLines attribute', + render: function() { + return ( + + + Maximum of one line no matter now much I write here. If I keep writing it{"'"}ll just truncate after one line + + + Maximum of two lines no matter now much I write here. If I keep writing it{"'"}ll just truncate after two lines + + + No maximum lines specified no matter now much I write here. If I keep writing it{"'"}ll just keep going and going + + + ); + }, +}]; + +var styles = StyleSheet.create({ + backgroundColorText: { + left: 5, + backgroundColor: 'rgba(100, 100, 100, 0.3)' + }, + entity: { + fontWeight: 'bold', + color: '#527fe4', + }, +}); diff --git a/Examples/UIExplorer/TextInputExample.js b/Examples/UIExplorer/TextInputExample.js new file mode 100644 index 0000000000..656ccd4fcd --- /dev/null +++ b/Examples/UIExplorer/TextInputExample.js @@ -0,0 +1,188 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule TextInputExample + */ +'use strict'; + +var React = require('react-native'); +var { + Text, + TextInput, + View, + StyleSheet, +} = React; + +var WithLabel = React.createClass({ + render: function() { + return ( + + + {this.props.label} + + {this.props.children} + + ); + } +}); + +var TextEventsExample = React.createClass({ + getInitialState: function() { + return { + curText: '', + prevText: '', + }; + }, + + updateText: function(text) { + this.setState({ + curText: text, + prevText: this.state.curText, + }); + }, + + render: function() { + return ( + + this.updateText('onFocus')} + onBlur={() => this.updateText('onBlur')} + onChange={(event) => this.updateText( + 'onChange text: ' + event.nativeEvent.text + )} + onEndEditing={(event) => this.updateText( + 'onEndEditing text: ' + event.nativeEvent.text + )} + onSubmitEditing={(event) => this.updateText( + 'onSubmitEditing text: ' + event.nativeEvent.text + )} + style={styles.default} + /> + + {this.state.curText}{'\n'} + (prev: {this.state.prevText}) + + + ); + } +}); + +var styles = StyleSheet.create({ + page: { + paddingBottom: 300, + }, + default: { + height: 26, + borderWidth: 0.5, + borderColor: '#0f0f0f', + padding: 4, + flex: 1, + fontSize: 13, + }, + multiline: { + borderWidth: 0.5, + borderColor: '#0f0f0f', + flex: 1, + fontSize: 13, + height: 50, + }, + eventLabel: { + margin: 3, + fontSize: 12, + }, + labelContainer: { + flexDirection: 'row', + marginVertical: 2, + flex: 1, + }, + label: { + width: 80, + justifyContent: 'flex-end', + flexDirection: 'row', + marginRight: 10, + paddingTop: 2, + }, +}); + +exports.title = ''; +exports.description = 'Single-line text inputs.'; +exports.examples = [ + { + title: 'Auto-focus', + render: function() { + return ; + } + }, + { + title: 'Auto-capitalize', + render: function() { + return ( + + + + + + + + + + + + + + + ); + } + }, + { + title: 'Auto-correct', + render: function() { + return ( + + + + + + + + + ); + } + }, + { + title: 'Event handling', + render: () => , + }, + { + title: 'Colored input text', + render: function() { + return ( + + + + + ); + } + }, +]; diff --git a/Examples/UIExplorer/TouchableExample.js b/Examples/UIExplorer/TouchableExample.js new file mode 100644 index 0000000000..7aa2be01c6 --- /dev/null +++ b/Examples/UIExplorer/TouchableExample.js @@ -0,0 +1,134 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule TouchableExample + */ +'use strict'; + +var React = require('react-native'); +var { + PixelRatio, + Image, + StyleSheet, + Text, + TouchableHighlight, + View, +} = React; + +exports.title = ' and onPress'; +exports.examples = [ +{ + title: '', + description: 'TouchableHighlight works by adding an extra view with a ' + + 'black background under the single child view. This works best when the ' + + 'child view is fully opaque, although it can be made to work as a simple ' + + 'background color change as well with the activeOpacity and ' + + 'underlayColor props.', + render: function() { + return ( + + + console.log('stock THW image - highlight')}> + + + console.log('custom THW text - hightlight')}> + + + Tap Here For Custom Highlight! + + + + + + ); + }, +}, { + title: ' with highlight', + render: function() { + return ; + }, +}]; + +var TextOnPressBox = React.createClass({ + getInitialState: function() { + return { + timesPressed: 0, + }; + }, + textOnPress: function() { + this.setState({ + timesPressed: this.state.timesPressed + 1, + }); + }, + render: function() { + var textLog = ''; + if (this.state.timesPressed > 1) { + textLog = this.state.timesPressed + 'x text onPress'; + } else if (this.state.timesPressed > 0) { + textLog = 'text onPress'; + } + + return ( + + + Text has built-in onPress handling + + + + {textLog} + + + + ); + } +}); + +var heartImage = {uri: 'https://pbs.twimg.com/media/BlXBfT3CQAA6cVZ.png:small'}; + +var styles = StyleSheet.create({ + row: { + alignItems: 'center', + flexDirection: 'row', + }, + icon: { + width: 24, + height: 24, + }, + image: { + width: 50, + height: 50, + }, + text: { + fontSize: 16, + }, + wrapper: { + borderRadius: 8, + }, + wrapperCustom: { + borderRadius: 8, + padding: 6, + }, + logBox: { + padding: 20, + margin: 10, + borderWidth: 1 / PixelRatio.get(), + borderColor: '#f0f0f0', + backgroundColor: '#f9f9f9', + }, + textBlock: { + fontWeight: 'bold', + color: 'blue', + }, +}); diff --git a/Examples/UIExplorer/UIExplorerApp.js b/Examples/UIExplorer/UIExplorerApp.js new file mode 100644 index 0000000000..7c73d4ee30 --- /dev/null +++ b/Examples/UIExplorer/UIExplorerApp.js @@ -0,0 +1,44 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule UIExplorerApp + */ +'use strict'; + +var React = require('react-native/addons'); +var UIExplorerList = require('./UIExplorerList'); + +var { + AppRegistry, + NavigatorIOS, + StyleSheet, +} = React; + + +var UIExplorerApp = React.createClass({ + render: function() { + return ( + + ); + } +}); + +var styles = StyleSheet.create({ + container: { + flex: 1, + }, + itemWrapper: { + backgroundColor: '#eaeaea', + }, +}); + +AppRegistry.registerComponent('UIExplorerApp', () => UIExplorerApp); + +module.exports = UIExplorerApp; diff --git a/Examples/UIExplorer/UIExplorerBlock.js b/Examples/UIExplorer/UIExplorerBlock.js new file mode 100644 index 0000000000..4c96ce86d5 --- /dev/null +++ b/Examples/UIExplorer/UIExplorerBlock.js @@ -0,0 +1,93 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule UIExplorerBlock + */ +'use strict'; + +var React = require('react-native'); +var { + StyleSheet, + Text, + View, +} = React; + +var UIExplorerBlock = React.createClass({ + propTypes: { + title: React.PropTypes.string, + description: React.PropTypes.string, + }, + + getInitialState: function() { + return {description: null}; + }, + + render: function() { + var description; + if (this.props.description) { + description = + + {this.props.description} + ; + } + + return ( + + + + {this.props.title} + + {description} + + + {this.props.children} + + + ); + } +}); + +var styles = StyleSheet.create({ + container: { + borderRadius: 3, + borderWidth: 0.5, + borderColor: '#d6d7da', + backgroundColor: '#ffffff', + margin: 10, + marginVertical: 5, + overflow: 'hidden', + }, + titleContainer: { + borderWidth: 0.5, + borderColor: '#d6d7da', + backgroundColor: '#f6f7f8', + paddingHorizontal: 10, + paddingVertical: 5, + }, + titleRow: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + titleText: { + fontSize: 14, + fontWeight: 'bold', + }, + descriptionText: { + fontSize: 14, + }, + disclosure: { + position: 'absolute', + top: 0, + right: 0, + padding: 10, + }, + disclosureIcon: { + width: 12, + height: 8, + }, + children: { + padding: 10, + } +}); + +module.exports = UIExplorerBlock; diff --git a/Examples/UIExplorer/UIExplorerList.js b/Examples/UIExplorer/UIExplorerList.js new file mode 100644 index 0000000000..56734c70fa --- /dev/null +++ b/Examples/UIExplorer/UIExplorerList.js @@ -0,0 +1,113 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + */ +'use strict'; + +var React = require('react-native/addons'); +var { + PixelRatio, + ScrollView, + StyleSheet, + Text, + TouchableHighlight, + View, + invariant, +} = React; + +var createExamplePage = require('./createExamplePage'); + +var EXAMPLES = [ + require('./ViewExample'), + require('./LayoutExample'), + require('./TextExample.ios'), + require('./TextInputExample'), + require('./ExpandingTextExample'), + require('./ImageExample'), + require('./ListViewSimpleExample'), + require('./ListViewPagingExample'), + require('./NavigatorIOSExample'), + require('./StatusBarIOSExample'), + require('./PointerEventsExample'), + require('./TouchableExample'), + require('./ActivityIndicatorExample'), + require('./ScrollViewExample'), +]; + +var UIExplorerList = React.createClass({ + render: function() { + return ( + + + + {EXAMPLES.map(this._renderRow)} + + + + ); + }, + + _renderRow: function(example, i) { + invariant(example.title, 'Example must provide a title.'); + return ( + + this._onPressRow(example)}> + + + {example.title} + + + {example.description} + + + + + + ); + }, + + _onPressRow: function(example) { + var Component = example.examples ? + createExamplePage(null, example) : + example; + this.props.navigator.push({ + title: Component.title, + component: Component, + }); + }, +}); + +var styles = StyleSheet.create({ + list: { + backgroundColor: '#eeeeee', + }, + group: { + backgroundColor: 'white', + marginVertical: 25, + }, + line: { + backgroundColor: '#bbbbbb', + height: 1 / PixelRatio.get(), + }, + row: { + backgroundColor: 'white', + justifyContent: 'center', + paddingHorizontal: 15, + paddingVertical: 8, + }, + separator: { + height: 1 / PixelRatio.get(), + backgroundColor: '#bbbbbb', + marginLeft: 15, + }, + rowTitleText: { + fontSize: 17, + fontWeight: 'bold', + }, + rowDetailText: { + fontSize: 15, + color: '#888888', + lineHeight: 20, + }, +}); + +module.exports = UIExplorerList; diff --git a/Examples/UIExplorer/UIExplorerPage.js b/Examples/UIExplorer/UIExplorerPage.js new file mode 100644 index 0000000000..2d72f62c71 --- /dev/null +++ b/Examples/UIExplorer/UIExplorerPage.js @@ -0,0 +1,68 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule UIExplorerPage + */ +'use strict'; + +var React = require('react-native'); +var { + ScrollView, + StyleSheet, + View, +} = React; + +var UIExplorerTitle = require('./UIExplorerTitle'); + +var UIExplorerPage = React.createClass({ + + propTypes: { + keyboardShouldPersistTaps: React.PropTypes.bool, + noScroll: React.PropTypes.bool, + noSpacer: React.PropTypes.bool, + }, + + render: function() { + var ContentWrapper; + var wrapperProps = {}; + if (this.props.noScroll) { + ContentWrapper = View; + } else { + ContentWrapper = ScrollView; + wrapperProps.keyboardShouldPeristTaps = true; + wrapperProps.keyboardDismissMode = + ScrollView.keyboardDismissMode.Interactive; + } + var title = this.props.title ? + : + null; + var spacer = this.props.noSpacer ? null : ; + return ( + + {title} + + {this.props.children} + {spacer} + + + ); + }, +}); + +var styles = StyleSheet.create({ + container: { + backgroundColor: '#e9eaed', + paddingTop: 15, + flex: 1, + }, + spacer: { + height: 270, + }, + wrapper: { + flex: 1, + }, +}); + +module.exports = UIExplorerPage; diff --git a/Examples/UIExplorer/UIExplorerTitle.js b/Examples/UIExplorer/UIExplorerTitle.js new file mode 100644 index 0000000000..a8fb4ba8dd --- /dev/null +++ b/Examples/UIExplorer/UIExplorerTitle.js @@ -0,0 +1,43 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule UIExplorerTitle + */ +'use strict'; + +var React = require('react-native'); +var { + StyleSheet, + Text, + View, +} = React; + +var UIExplorerTitle = React.createClass({ + render: function() { + return ( + + + {this.props.title} + + + ); + } +}); + +var styles = StyleSheet.create({ + container: { + borderRadius: 4, + borderWidth: 0.5, + borderColor: '#d6d7da', + margin: 10, + height: 45, + padding: 10, + backgroundColor: 'white', + }, + text: { + fontSize: 19, + fontWeight: 'bold', + }, +}); + +module.exports = UIExplorerTitle; diff --git a/Examples/UIExplorer/ViewExample.js b/Examples/UIExplorer/ViewExample.js new file mode 100644 index 0000000000..660c191bb2 --- /dev/null +++ b/Examples/UIExplorer/ViewExample.js @@ -0,0 +1,123 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ViewExample + */ +'use strict'; + +var React = require('react-native'); +var { + StyleSheet, + Text, + View, +} = React; + +var UIExplorerBlock = require('./UIExplorerBlock'); +var UIExplorerPage = require('./UIExplorerPage'); + +var ViewExample = React.createClass({ + statics: { + title: '', + description: 'Basic building block of all UI.' + }, + getInitialState: function() { + return { + textBorderExampleHeight: 20, + }; + }, + + render: function() { + return ( + '}> + + + + Blue background + + + + + + 5px blue border + + + + + + 5px padding + + + 5px margin + + + + 5px margin and padding, + + + widthAutonomous=true + + + + + + + + Too much use of `borderRadius` (especially large radii) on + anything which is scrolling may result in dropped frames. + Use sparingly. + + + + + + + + + + + Overflow hidden + + + + + Overflow visible + + + + + + Opacity 0 + Opacity 0.1 + Opacity 0.3 + Opacity 0.5 + Opacity 0.7 + Opacity 0.9 + Opacity 1 + + + ); + }, + + updateHeight: function() { + var height = this.state.textBorderExampleHeight === 50 ? 20 : 50; + this.setState({textBorderExampleHeight: height}); + }, +}); + +var styles = StyleSheet.create({ + box: { + backgroundColor: '#527FE4', + borderColor: '#000033', + borderWidth: 1, + } +}); + +module.exports = ViewExample; diff --git a/Examples/UIExplorer/createExamplePage.js b/Examples/UIExplorer/createExamplePage.js new file mode 100644 index 0000000000..39ed81133c --- /dev/null +++ b/Examples/UIExplorer/createExamplePage.js @@ -0,0 +1,60 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule createExamplePage + */ +'use strict'; + +var React = require('react-native'); +var UIExplorerBlock = require('./UIExplorerBlock'); +var UIExplorerPage = require('./UIExplorerPage'); + +var invariant = require('invariant'); + +var createExamplePage = function(title, exampleModule) { + invariant(!!exampleModule.examples, 'The module must have examples'); + + var ExamplePage = React.createClass({ + statics: { + title: exampleModule.title, + description: exampleModule.description, + }, + + getBlock: function(example, i) { + // Hack warning: This is a hack because the www UI explorer requires + // renderComponent to be called. + var originalRenderComponent = React.renderComponent; + var originalRender = React.render; + var renderedComponent; + React.render = React.renderComponent = function(element, container) { + renderedComponent = element; + }; + var result = example.render(null); + if (result) { + renderedComponent = result; + } + React.renderComponent = originalRenderComponent; + React.render = originalRender; + return ( + + {renderedComponent} + + ); + }, + + render: function() { + return ( + + {exampleModule.examples.map(this.getBlock)} + + ); + } + }); + + return ExamplePage; +}; + +module.exports = createExamplePage; diff --git a/Examples/UIExplorer/main.m b/Examples/UIExplorer/main.m new file mode 100644 index 0000000000..357a233b12 --- /dev/null +++ b/Examples/UIExplorer/main.m @@ -0,0 +1,10 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import +#import "AppDelegate.h" + +int main(int argc, char * argv[]) { + @autoreleasepool { + return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..56a1131ca0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2015, Facebook, Inc. All rights reserved. + * + * React Native software is currently provided by Facebook to you for + * non-commercial testing and evaluation purposes only. Facebook reserves all + * rights not expressly granted. An open source version of this software will + * be provided at a later date. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL + * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN + * ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ diff --git a/Libraries/Animation/LayoutAnimation.js b/Libraries/Animation/LayoutAnimation.js new file mode 100644 index 0000000000..5ce5648b30 --- /dev/null +++ b/Libraries/Animation/LayoutAnimation.js @@ -0,0 +1,89 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule LayoutAnimation + */ +'use strict'; + +var PropTypes = require('ReactPropTypes'); +var RKUIManager = require('NativeModules').RKUIManager; + +var createStrictShapeTypeChecker = require('createStrictShapeTypeChecker'); +var keyMirror = require('keyMirror'); + +var Types = keyMirror({ + spring: true, + linear: true, + easeInEaseOut: true, + easeIn: true, + easeOut: true, +}); + +var Properties = keyMirror({ + opacity: true, + scaleXY: true, +}); + +var animChecker = createStrictShapeTypeChecker({ + duration: PropTypes.number, + delay: PropTypes.number, + springDamping: PropTypes.number, + initialVelocity: PropTypes.number, + type: PropTypes.oneOf( + Object.keys(Types) + ), + property: PropTypes.oneOf( // Only applies to create/delete + Object.keys(Properties) + ), +}); + +var configChecker = createStrictShapeTypeChecker({ + duration: PropTypes.number.isRequired, + create: animChecker, + update: animChecker, + delete: animChecker, +}); + +var LayoutAnimation = { + configureNext(config, onAnimationDidEnd, onError) { + configChecker({config}, 'config', 'LayoutAnimation.configureNext'); + RKUIManager.configureNextLayoutAnimation(config, onAnimationDidEnd, onError); + }, + create(duration, type, creationProp) { + return { + duration, + create: { + type, + property: creationProp, + }, + update: { + type, + }, + }; + }, + Types: Types, + Properties: Properties, + configChecker: configChecker, +}; + +LayoutAnimation.Presets = { + easeInEaseOut: LayoutAnimation.create( + 0.3, Types.easeInEaseOut, Properties.opacity + ), + linear: LayoutAnimation.create( + 0.5, Types.linear, Properties.opacity + ), + spring: { + duration: 0.7, + create: { + type: Types.linear, + property: Properties.opacity, + }, + update: { + type: Types.spring, + springDamping: 0.4, + }, + }, +}; + +module.exports = LayoutAnimation; diff --git a/Libraries/Animation/POPAnimationMixin.js b/Libraries/Animation/POPAnimationMixin.js new file mode 100644 index 0000000000..a3f4b7defe --- /dev/null +++ b/Libraries/Animation/POPAnimationMixin.js @@ -0,0 +1,250 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule POPAnimationMixin + * @flow + */ +'use strict'; + +var POPAnimation = require('POPAnimation'); +if (!POPAnimation) { + // POP animation isn't available in the OSS fork - this is a temporary + // workaround to enable its availability to be determined at runtime. + module.exports = null; +} else { + +var invariant = require('invariant'); +var warning = require('warning'); + +var POPAnimationMixin = { + /** + * Different ways to interpolate between beginning and end states + * of properties during animation, such as spring, linear, and decay. + */ + AnimationTypes: POPAnimation.Types, + AnimationProperties: POPAnimation.Properties, + + getInitialState: function(): Object { + return { + _currentAnimationsByNodeHandle: {}, + }; + }, + + _ensureBookkeepingSetup: function(nodeHandle: any) { + if (!this.state._currentAnimationsByNodeHandle[nodeHandle]) { + this.state._currentAnimationsByNodeHandle[nodeHandle] = []; + } + }, + + /** + * Start animating the View with ref `refKey`. + * + * @param {key} refKey The key to reference the View to be animated. + * + * @param {number|Object} anim Either the identifier returned by + * POPAnimation.create* or an object defining all the necessary + * properties of the animation you wish to start (including type, matching + * an entry in AnimationTypes). + * + * @param {func} doneCallback A callback fired when the animation is done, and + * is passed a `finished` param that indicates whether the animation + * completely finished, or was interrupted. + */ + startAnimation: function( + refKey: string, + anim: number | {type: number; property: number;}, + doneCallback: (finished: bool) => void + ) { + var animID: number = 0; + if (typeof anim === 'number') { + animID = anim; + } else { + invariant( + anim instanceof Object && + anim.type !== undefined && + anim.property !== undefined, + 'Animation definitions must specify a type of animation and a ' + + 'property to animate.' + ); + animID = POPAnimation.createAnimation(anim.type, anim); + } + invariant( + this.refs[refKey], + 'Invalid refKey ' + refKey + ' for anim:\n' + JSON.stringify(anim) + + '\nvalid refs: ' + JSON.stringify(Object.keys(this.refs)) + ); + var refNodeHandle = this.refs[refKey].getNodeHandle(); + this.startAnimationWithNodeHandle(refNodeHandle, animID, doneCallback); + }, + + /** + * Starts an animation on a native node. + * + * @param {NodeHandle} nodeHandle Handle to underlying native node. + * @see `startAnimation`. + */ + startAnimationWithNodeHandle: function( + nodeHandle: any, + animID: number, + doneCallback: (finished: bool) => void + ) { + this._ensureBookkeepingSetup(nodeHandle); + var animations = this.state._currentAnimationsByNodeHandle[nodeHandle]; + var animIndex = animations.length; + animations.push(animID); + var cleanupWrapper = (finished) => { + if (!this.isMounted()) { + return; + } + animations[animIndex] = 0; // zero it out so we don't try to stop it + var allDone = true; + for (var ii = 0; ii < animations.length; ii++) { + if (animations[ii]) { + allDone = false; + break; + } + } + if (allDone) { + this.state._currentAnimationsByNodeHandle[nodeHandle] = undefined; + } + doneCallback && doneCallback(finished); + }; + POPAnimation.addAnimation(nodeHandle, animID, cleanupWrapper); + }, + + /** + * Starts multiple animations with one shared callback that is called when all + * animations complete. + * + * @param {Array(Object} animations Array of objects defining all the + * animations to start, each with shape `{ref|nodeHandle, anim}`. + * @param {func} onSuccess A callback fired when all animations have returned, + * and is passed a finished arg that is true if all animations finished + * completely. + * @param {func} onFailure Not supported yet. + */ + startAnimations: function( + animations: Array, + onSuccess: (finished: boolean) => void, + onFailure: () => void + ) { + var numReturned = 0; + var numFinished = 0; + var numAnimations = animations.length; + var metaCallback = (finished) => { + if (finished) { + ++numFinished; + } + if (++numReturned === numAnimations) { + onSuccess && onSuccess(numFinished === numAnimations); + } + }; + animations.forEach((anim) => { + warning( + anim.ref != null || anim.nodeHandle != null && + !anim.ref !== !anim.nodeHandle, + 'Animations must be specified with either ref xor nodeHandle' + ); + if (anim.ref) { + this.startAnimation(anim.ref, anim.anim, metaCallback); + } else if (anim.nodeHandle) { + this.startAnimationWithNodeHandle(anim.nodeHandle, anim.anim, metaCallback); + } + }); + }, + + /** + * Stop any and all animations operating on the View with native node handle + * `nodeHandle`. + * + * @param {NodeHandle} component The instance to stop animations + * on. Do not pass a composite component. + */ + stopNodeHandleAnimations: function(nodeHandle: any) { + if (!this.state._currentAnimationsByNodeHandle[nodeHandle]) { + return; + } + var anims = this.state._currentAnimationsByNodeHandle[nodeHandle]; + for (var i = 0; i < anims.length; i++) { + var anim = anims[i]; + if (anim) { + // Note: Converting the string key to a number `nodeHandle`. + POPAnimation.removeAnimation(+nodeHandle, anim); + } + } + this.state._currentAnimationsByNodeHandle[nodeHandle] = undefined; + }, + + /** + * Stop any and all animations operating on the View with ref `refKey`. + * + * @param {key} refKey The key to reference the View to be animated. + */ + stopAnimations: function(refKey: string) { + invariant(this.refs[refKey], 'invalid ref'); + this.stopNodeHandleAnimations(this.refs[refKey].getNodeHandle()); + }, + + /** + * Stop any and all animations created by this component on itself and + * subviews. + */ + stopAllAnimations: function() { + for (var nodeHandle in this.state._currentAnimationsByNodeHandle) { + this.stopNodeHandleAnimations(nodeHandle); + } + }, + + /** + * Animates size and position of a view referenced by `refKey` to a specific + * frame. + * + * @param {key} refKey ref key for view to animate. + * @param {Object} frame The frame to animate the view to, specified as {left, + * top, width, height}. + * @param {const} type What type of interpolation to use, selected from + * `inperpolationTypes`. + * @param {Object} event Event encapsulating synthetic and native data that + * may have triggered this animation. Velocity is extracted from it if + * possible and applied to the animation. + * @param {func} doneCallback A callback fired when the animation is done, and + * is passed a `finished` param that indicates whether the animation + * completely finished, or was interrupted. + */ + animateToFrame: function( + refKey: string, + frame: {left: number; top: number; width: number; height: number;}, + type: number, + velocity: number, + doneCallback: (finished: boolean) => void + ) { + var animFrame = { // Animations use a centered coordinate system. + x: frame.left + frame.width / 2, + y: frame.top + frame.height / 2, + w: frame.width, + h: frame.height + }; + frame = undefined; + var velocity = velocity || [0, 0]; + var posAnim = POPAnimation.createAnimation(type, { + property: POPAnimation.Properties.position, + toValue: [animFrame.x, animFrame.y], + velocity: velocity, + }); + var sizeAnim = POPAnimation.createAnimation(type, { + property: POPAnimation.Properties.size, + toValue: [animFrame.w, animFrame.h] + }); + this.startAnimation(refKey, posAnim, doneCallback); + this.startAnimation(refKey, sizeAnim); + }, + + // Cleanup any potentially leaked animations. + componentWillUnmount: function() { + this.stopAllAnimations(); + } +}; + +module.exports = POPAnimationMixin; + +} diff --git a/Libraries/AppRegistry/AppRegistry.js b/Libraries/AppRegistry/AppRegistry.js new file mode 100644 index 0000000000..a11f5d4941 --- /dev/null +++ b/Libraries/AppRegistry/AppRegistry.js @@ -0,0 +1,68 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule AppRegistry + */ +'use strict'; + +var invariant = require('invariant'); +var renderApplication = require('renderApplication'); + +if (__DEV__) { + // In order to use Cmd+P to record/dump perf data, we need to make sure + // this module is available in the bundle + require('RCTRenderingPerf'); +} + +var runnables = {}; + +/** + * `AppRegistry` is the JS entry point to running all React Native apps. App + * root components should register themselves with + * `AppRegistry.registerComponent`, then the native system can load the bundle + * for the app and then actually run the app when it's ready by invoking + * `AppRegistry.runApplication`. + * + * `AppRegistry` should be `require`d early in the `require` sequence to make + * sure the JS execution environment is setup before other modules are + * `require`d. + */ +var AppRegistry = { + registerConfig: function(config) { + for (var i = 0; i < config.length; ++i) { + if (config[i].run) { + AppRegistry.registerRunnable(config[i].appKey, config[i].run); + } else { + AppRegistry.registerComponent(config[i].appKey, config[i].component); + } + } + }, + + registerComponent: function(appKey, getComponentFunc) { + runnables[appKey] = { + run: (appParameters) => + renderApplication(getComponentFunc(), appParameters.initialProps, appParameters.rootTag) + }; + return appKey; + }, + + registerRunnable: function(appKey, func) { + runnables[appKey] = {run: func}; + return appKey; + }, + + runApplication: function(appKey, appParameters) { + console.log( + 'Running application "' + appKey + '" with appParams: ', + appParameters + ); + + invariant( + runnables[appKey] && runnables[appKey].run, + 'Application ' + appKey + ' has not been registered.' + ); + runnables[appKey].run(appParameters); + }, +}; + +module.exports = AppRegistry; diff --git a/Libraries/BatchedBridge/BatchedBridgedModules/NativeModules.js b/Libraries/BatchedBridge/BatchedBridgedModules/NativeModules.js new file mode 100644 index 0000000000..6805285754 --- /dev/null +++ b/Libraries/BatchedBridge/BatchedBridgedModules/NativeModules.js @@ -0,0 +1,16 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule NativeModules + */ +'use strict'; + +var NativeModules = require('BatchedBridge').RemoteModules; + +// Dirty hack to support old (RK) and new (RCT) native module name conventions +Object.keys(NativeModules).forEach((moduleName) => { + var rkModuleName = moduleName.replace(/^RCT/, 'RK'); + NativeModules[rkModuleName] = NativeModules[moduleName]; +}); + +module.exports = NativeModules; diff --git a/Libraries/BatchedBridge/BatchedBridgedModules/NativeModulesDeprecated.js b/Libraries/BatchedBridge/BatchedBridgedModules/NativeModulesDeprecated.js new file mode 100644 index 0000000000..2d83d6f9a5 --- /dev/null +++ b/Libraries/BatchedBridge/BatchedBridgedModules/NativeModulesDeprecated.js @@ -0,0 +1,16 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule NativeModulesDeprecated + */ +'use strict'; + +var RemoteModulesDeprecated = require('BatchedBridge').RemoteModulesDeprecated; + +// Dirty hack to support old (RK) and new (RCT) native module name conventions +Object.keys(RemoteModulesDeprecated).forEach((moduleName) => { + var rkModuleName = moduleName.replace(/^RCT/, 'RK'); + RemoteModulesDeprecated[rkModuleName] = RemoteModulesDeprecated[moduleName]; +}); + +module.exports = RemoteModulesDeprecated; diff --git a/Libraries/BatchedBridge/BatchedBridgedModules/POPAnimation.js b/Libraries/BatchedBridge/BatchedBridgedModules/POPAnimation.js new file mode 100644 index 0000000000..8ec0bd62fb --- /dev/null +++ b/Libraries/BatchedBridge/BatchedBridgedModules/POPAnimation.js @@ -0,0 +1,166 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule POPAnimation + */ +'use strict'; + +var RKPOPAnimationManager = require('NativeModulesDeprecated').RKPOPAnimationManager; +if (!RKPOPAnimationManager) { + // POP animation isn't available in the OSS fork - this is a temporary + // workaround to enable its availability to be determined at runtime. + module.exports = null; +} else { + +var ReactPropTypes = require('ReactPropTypes'); +var createStrictShapeTypeChecker = require('createStrictShapeTypeChecker'); +var getObjectValues = require('getObjectValues'); +var invariant = require('invariant'); +var merge = require('merge'); + +var RKTypes = RKPOPAnimationManager.Types; +var RKProperties = RKPOPAnimationManager.Properties; + +var Properties = { + bounds: RKProperties.bounds, + opacity: RKProperties.opacity, + position: RKProperties.position, + positionX: RKProperties.positionX, + positionY: RKProperties.positionY, + zPosition: RKProperties.zPosition, + rotation: RKProperties.rotation, + rotationX: RKProperties.rotationX, + rotationY: RKProperties.rotationY, + scaleX: RKProperties.scaleX, + scaleXY: RKProperties.scaleXY, + scaleY: RKProperties.scaleY, + shadowColor: RKProperties.shadowColor, + shadowOffset: RKProperties.shadowOffset, + shadowOpacity: RKProperties.shadowOpacity, + shadowRadius: RKProperties.shadowRadius, + size: RKProperties.size, + subscaleXY: RKProperties.subscaleXY, + subtranslationX: RKProperties.subtranslationX, + subtranslationXY: RKProperties.subtranslationXY, + subtranslationY: RKProperties.subtranslationY, + subtranslationZ: RKProperties.subtranslationZ, + translationX: RKProperties.translationX, + translationXY: RKProperties.translationXY, + translationY: RKProperties.translationY, + translationZ: RKProperties.translationZ, +}; + +var Types = { + decay: RKTypes.decay, + easeIn: RKTypes.easeIn, + easeInEaseOut: RKTypes.easeInEaseOut, + easeOut: RKTypes.easeOut, + linear: RKTypes.linear, + spring: RKTypes.spring, +}; + +var POPAnimation = { + Types: Types, + Properties: Properties, + + attributeChecker: createStrictShapeTypeChecker({ + type: ReactPropTypes.oneOf(getObjectValues(Types)), + property: ReactPropTypes.oneOf(getObjectValues(Properties)), + fromValue: ReactPropTypes.any, + toValue: ReactPropTypes.any, + duration: ReactPropTypes.any, + velocity: ReactPropTypes.any, + deceleration: ReactPropTypes.any, + springBounciness: ReactPropTypes.any, + dynamicsFriction: ReactPropTypes.any, + dynamicsMass: ReactPropTypes.any, + dynamicsTension: ReactPropTypes.any, + }), + + lastUsedTag: 0, + allocateTagForAnimation: function() { + return ++this.lastUsedTag; + }, + + createAnimation: function(typeName, attrs) { + var tag = this.allocateTagForAnimation(); + + if (__DEV__) { + POPAnimation.attributeChecker( + {attrs}, + 'attrs', + 'POPAnimation.createAnimation' + ); + POPAnimation.attributeChecker( + {attrs: {type: typeName}}, + 'attrs', + 'POPAnimation.createAnimation' + ); + } + + RKPOPAnimationManager.createAnimationInternal(tag, typeName, attrs); + return tag; + }, + + createSpringAnimation: function(attrs) { + return this.createAnimation(this.Types.spring, attrs); + }, + + createDecayAnimation: function(attrs) { + return this.createAnimation(this.Types.decay, attrs); + }, + + createLinearAnimation: function(attrs) { + return this.createAnimation(this.Types.linear, attrs); + }, + + createEaseInAnimation: function(attrs) { + return this.createAnimation(this.Types.easeIn, attrs); + }, + + createEaseOutAnimation: function(attrs) { + return this.createAnimation(this.Types.easeOut, attrs); + }, + + createEaseInEaseOutAnimation: function(attrs) { + return this.createAnimation(this.Types.easeInEaseOut, attrs); + }, + + addAnimation: function(nodeHandle, anim, callback) { + RKPOPAnimationManager.addAnimation(nodeHandle, anim, callback); + }, + + removeAnimation: function(nodeHandle, anim) { + RKPOPAnimationManager.removeAnimation(nodeHandle, anim); + }, +}; + +// Make sure that we correctly propagate RKPOPAnimationManager constants +// to POPAnimation +if (__DEV__) { + var allProperties = merge( + RKPOPAnimationManager.Properties, + RKPOPAnimationManager.Properties + ); + for (var key in allProperties) { + invariant( + POPAnimation.Properties[key] === RKPOPAnimationManager.Properties[key], + 'POPAnimation doesn\'t copy property ' + key + ' correctly' + ); + } + + var allTypes = merge( + RKPOPAnimationManager.Types, + RKPOPAnimationManager.Types + ); + for (var key in allTypes) { + invariant( + POPAnimation.Types[key] === RKPOPAnimationManager.Types[key], + 'POPAnimation doesn\'t copy type ' + key + ' correctly' + ); + } +} + +module.exports = POPAnimation; + +} diff --git a/Libraries/BatchedBridge/BatchedBridgedModules/RCTEventEmitter.js b/Libraries/BatchedBridge/BatchedBridgedModules/RCTEventEmitter.js new file mode 100644 index 0000000000..9ef7d965fc --- /dev/null +++ b/Libraries/BatchedBridge/BatchedBridgedModules/RCTEventEmitter.js @@ -0,0 +1,11 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule RCTEventEmitter + */ +'use strict'; + +var ReactIOSEventEmitter = require('ReactIOSEventEmitter'); + +// Completely locally implemented - no native hooks. +module.exports = ReactIOSEventEmitter; diff --git a/Libraries/BatchedBridge/BatchedBridgedModules/RCTJSTimers.js b/Libraries/BatchedBridge/BatchedBridgedModules/RCTJSTimers.js new file mode 100644 index 0000000000..68b5118ff6 --- /dev/null +++ b/Libraries/BatchedBridge/BatchedBridgedModules/RCTJSTimers.js @@ -0,0 +1,12 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule RCTJSTimers + */ +'use strict'; + +var JSTimersExecution = require('JSTimersExecution'); + +var RCTJSTimers = JSTimersExecution; + +module.exports = RCTJSTimers; diff --git a/Libraries/BatchedBridge/BatchedBridgedModules/RKAlertManager.ios.js b/Libraries/BatchedBridge/BatchedBridgedModules/RKAlertManager.ios.js new file mode 100644 index 0000000000..39d3cbc279 --- /dev/null +++ b/Libraries/BatchedBridge/BatchedBridgedModules/RKAlertManager.ios.js @@ -0,0 +1,10 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule RKAlertManager + */ +'use strict'; + +var RKAlertManager = require('NativeModulesDeprecated').RKAlertManager; + +module.exports = RKAlertManager; diff --git a/Libraries/BatchedBridge/BatchingImplementation/BatchedBridge.js b/Libraries/BatchedBridge/BatchingImplementation/BatchedBridge.js new file mode 100644 index 0000000000..b5ceec184a --- /dev/null +++ b/Libraries/BatchedBridge/BatchingImplementation/BatchedBridge.js @@ -0,0 +1,32 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule BatchedBridge + */ +'use strict'; + +var BatchedBridgeFactory = require('BatchedBridgeFactory'); +var MessageQueue = require('MessageQueue'); + +/** + * Signature that matches the native IOS modules/methods that are exposed. We + * indicate which ones accept a callback. The order of modules and methods + * within them implicitly define their numerical *ID* that will be used to + * describe method calls across the wire. This is so that memory is used + * efficiently and we do not need to copy strings in native land - or across any + * wire. + */ + +var remoteModulesConfig = __fbBatchedBridgeConfig.remoteModuleConfig; +var localModulesConfig = __fbBatchedBridgeConfig.localModulesConfig; + + +var BatchedBridge = BatchedBridgeFactory.create( + MessageQueue, + remoteModulesConfig, + localModulesConfig +); + +BatchedBridge._config = remoteModulesConfig; + +module.exports = BatchedBridge; diff --git a/Libraries/BatchedBridge/BatchingImplementation/BatchedBridgeFactory.js b/Libraries/BatchedBridge/BatchingImplementation/BatchedBridgeFactory.js new file mode 100644 index 0000000000..d6c7f5f3f6 --- /dev/null +++ b/Libraries/BatchedBridge/BatchingImplementation/BatchedBridgeFactory.js @@ -0,0 +1,106 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule BatchedBridgeFactory + */ +'use strict'; + +var invariant = require('invariant'); +var keyMirror = require('keyMirror'); +var mapObject = require('mapObject'); +var warning = require('warning'); + +var slice = Array.prototype.slice; + +var MethodTypes = keyMirror({ + remote: null, + local: null, +}); + +/** + * Creates remotely invokable modules. + */ +var BatchedBridgeFactory = { + MethodTypes: MethodTypes, + /** + * @deprecated: Remove callsites and delete this method. + * + * @param {MessageQueue} messageQueue Message queue that has been created with + * the `moduleConfig` (among others perhaps). + * @param {object} moduleConfig Configuration of module names/method + * names to callback types. + * @return {object} Remote representation of configured module. + */ + _createDeprecatedBridgedModule: function(messageQueue, moduleConfig, moduleName) { + var remoteModule = mapObject(moduleConfig.methods, function(methodConfig, memberName) { + return methodConfig.type === MethodTypes.local ? null : function() { + var lastArg = arguments.length ? arguments[arguments.length - 1] : null; + var hasCB = + typeof lastArg == 'function'; + var args = slice.call(arguments, 0, arguments.length - (hasCB ? 1 : 0)); + var cb = hasCB ? lastArg : null; + return messageQueue.callDeprecated(moduleName, memberName, args, cb); + }; + }); + for (var constName in moduleConfig.constants) { + warning(!remoteModule[constName], 'saw constant and method named %s', constName); + remoteModule[constName] = moduleConfig.constants[constName]; + } + return remoteModule; + }, + + /** + * @param {MessageQueue} messageQueue Message queue that has been created with + * the `moduleConfig` (among others perhaps). + * @param {object} moduleConfig Configuration of module names/method + * names to callback types. + * @return {object} Remote representation of configured module. + */ + _createBridgedModule: function(messageQueue, moduleConfig, moduleName) { + var remoteModule = mapObject(moduleConfig.methods, function(methodConfig, memberName) { + return methodConfig.type === MethodTypes.local ? null : function() { + var lastArg = arguments.length > 0 ? arguments[arguments.length - 1] : null; + var secondLastArg = arguments.length > 1 ? arguments[arguments.length - 2] : null; + var hasSuccCB = typeof lastArg === 'function'; + var hasErrorCB = typeof secondLastArg === 'function'; + var hasCBs = hasSuccCB; + invariant( + (hasSuccCB && hasErrorCB) || (!hasSuccCB && !hasErrorCB), + 'You must supply error callbacks and success callbacks or neither' + ); + var args = slice.call(arguments, 0, arguments.length - (hasCBs ? 2 : 0)); + var onSucc = hasCBs ? lastArg : null; + var onFail = hasCBs ? secondLastArg : null; + return messageQueue.call(moduleName, memberName, args, onFail, onSucc); + }; + }); + for (var constName in moduleConfig.constants) { + warning(!remoteModule[constName], 'saw constant and method named %s', constName); + remoteModule[constName] = moduleConfig.constants[constName]; + } + return remoteModule; + }, + + + create: function(MessageQueue, modulesConfig, localModulesConfig) { + var messageQueue = new MessageQueue(modulesConfig, localModulesConfig); + return { + callFunction: messageQueue.callFunction.bind(messageQueue), + callFunctionReturnFlushedQueue: + messageQueue.callFunctionReturnFlushedQueue.bind(messageQueue), + invokeCallback: messageQueue.invokeCallback.bind(messageQueue), + invokeCallbackAndReturnFlushedQueue: + messageQueue.invokeCallbackAndReturnFlushedQueue.bind(messageQueue), + flushedQueue: messageQueue.flushedQueue.bind(messageQueue), + // These deprecated modules do not accept an error callback. + RemoteModulesDeprecated: mapObject(modulesConfig, this._createDeprecatedBridgedModule.bind(this, messageQueue)), + RemoteModules: mapObject(modulesConfig, this._createBridgedModule.bind(this, messageQueue)), + setLoggingEnabled: messageQueue.setLoggingEnabled.bind(messageQueue), + getLoggedOutgoingItems: messageQueue.getLoggedOutgoingItems.bind(messageQueue), + getLoggedIncomingItems: messageQueue.getLoggedIncomingItems.bind(messageQueue), + replayPreviousLog: messageQueue.replayPreviousLog.bind(messageQueue) + }; + } +}; + +module.exports = BatchedBridgeFactory; diff --git a/Libraries/Components/ActivityIndicatorIOS/ActivityIndicatorIOS.ios.js b/Libraries/Components/ActivityIndicatorIOS/ActivityIndicatorIOS.ios.js new file mode 100644 index 0000000000..a281cf97aa --- /dev/null +++ b/Libraries/Components/ActivityIndicatorIOS/ActivityIndicatorIOS.ios.js @@ -0,0 +1,104 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ActivityIndicatorIOS + */ +'use strict'; + +var NativeMethodsMixin = require('NativeMethodsMixin'); +var NativeModulesDeprecated = require('NativeModulesDeprecated'); +var PropTypes = require('ReactPropTypes'); +var React = require('React'); +var ReactIOSViewAttributes = require('ReactIOSViewAttributes'); +var StyleSheet = require('StyleSheet'); +var View = require('View'); + +var createReactIOSNativeComponentClass = require('createReactIOSNativeComponentClass'); +var keyMirror = require('keyMirror'); +var keyOf = require('keyOf'); +var merge = require('merge'); + +var SpinnerSize = keyMirror({ + large: null, + small: null, +}); + +var GRAY = '#999999'; + +var ActivityIndicatorIOS = React.createClass({ + mixins: [NativeMethodsMixin], + + propTypes: { + /** + * Whether to show the indicator (true, the default) or hide it (false). + */ + animating: PropTypes.bool, + /** + * The foreground color of the spinner (default is gray). + */ + color: PropTypes.string, + /** + * The size of the spinner, must be one of: + * - ActivityIndicatorIOS.size.large + * - ActivityIndicatorIOS.size.small (default) + */ + size: PropTypes.oneOf([SpinnerSize.large, SpinnerSize.small]), + }, + + getDefaultProps: function() { + return { + animating: true, + size: SpinnerSize.small, + color: GRAY, + }; + }, + + statics: { + size: SpinnerSize, + }, + + render: function() { + var style = styles.sizeSmall; + var NativeConstants = NativeModulesDeprecated.RKUIManager.UIActivityIndicatorView.Constants; + var activityIndicatorViewStyle = NativeConstants.StyleWhite; + if (this.props.size == SpinnerSize.large) { + style = styles.sizeLarge; + activityIndicatorViewStyle = NativeConstants.StyleWhiteLarge; + } + return ( + + + + ); + } +}); + +var styles = StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + }, + sizeSmall: { + height: 20, + }, + sizeLarge: { + height: 36, + } +}); + +var UIActivityIndicatorView = createReactIOSNativeComponentClass({ + validAttributes: merge( + ReactIOSViewAttributes.UIView, { + activityIndicatorViewStyle: true, // UIActivityIndicatorViewStyle=UIActivityIndicatorViewStyleWhite + animating: true, + color: true, + }), + uiViewClassName: 'UIActivityIndicatorView', +}); + +module.exports = ActivityIndicatorIOS; diff --git a/Libraries/Components/Image/Image.ios.js b/Libraries/Components/Image/Image.ios.js new file mode 100644 index 0000000000..5926cb1443 --- /dev/null +++ b/Libraries/Components/Image/Image.ios.js @@ -0,0 +1,161 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule Image + */ +'use strict'; + +var EdgeInsetsPropType = require('EdgeInsetsPropType'); +var NativeMethodsMixin = require('NativeMethodsMixin'); +var NativeModulesDeprecated = require('NativeModulesDeprecated'); +var PropTypes = require('ReactPropTypes'); +var ImageResizeMode = require('ImageResizeMode'); +var ImageSourcePropType = require('ImageSourcePropType'); +var ImageStylePropTypes = require('ImageStylePropTypes'); +var React = require('React'); +var ReactIOSViewAttributes = require('ReactIOSViewAttributes'); +var StyleSheet = require('StyleSheet'); +var StyleSheetPropType = require('StyleSheetPropType'); + +var createReactIOSNativeComponentClass = require('createReactIOSNativeComponentClass'); +var flattenStyle = require('flattenStyle'); +var insetsDiffer = require('insetsDiffer'); +var invariant = require('invariant'); +var merge = require('merge'); +var warning = require('warning'); + +/** + * - A react component for displaying different types of images, + * including network images, static resources, temporary local images, and + * images from local disk, such as the camera roll. Example usage: + * + * renderImages: function() { + * return ( + * + * + * + * + * ); + * }, + * + * More example code in ImageExample.js + */ + +var Image = React.createClass({ + propTypes: { + source: ImageSourcePropType, + /** + * accessible - Whether this element should be revealed as an accessible + * element. + */ + accessible: PropTypes.bool, + /** + * accessibilityLabel - Custom string to display for accessibility. + */ + accessibilityLabel: PropTypes.string, + /** + * capInsets - When the image is resized, the corners of the size specified + * by capInsets will stay a fixed size, but the center content and borders + * of the image will be stretched. This is useful for creating resizable + * rounded buttons, shadows, and other resizable assets. More info: + * + * https://developer.apple.com/library/ios/documentation/UIKit/Reference/UIImage_Class/index.html#//apple_ref/occ/instm/UIImage/resizableImageWithCapInsets: + */ + capInsets: EdgeInsetsPropType, + style: StyleSheetPropType(ImageStylePropTypes), + /** + * testID - A unique identifier for this element to be used in UI Automation + * testing scripts. + */ + testID: PropTypes.string, + }, + + statics: { + resizeMode: ImageResizeMode, + sourcePropType: ImageSourcePropType, + }, + + mixins: [NativeMethodsMixin], + + /** + * `NativeMethodsMixin` will look for this when invoking `setNativeProps`. We + * make `this` look like an actual native component class. + */ + viewConfig: { + uiViewClassName: 'UIView', + validAttributes: ReactIOSViewAttributes.UIView + }, + + render: function() { + var style = flattenStyle([styles.base, this.props.style]); + var source = this.props.source; + var isNetwork = source.uri && source.uri.match(/^https?:/); + invariant( + !(isNetwork && source.isStatic), + 'static image uris cannot start with "http": "' + source.uri + '"' + ); + var isStored = !source.isStatic && !isNetwork; + var RawImage = isNetwork ? RKNetworkImage : RKStaticImage; + + if (this.props.style && this.props.style.tintColor) { + warning(RawImage === RKStaticImage, 'tintColor style only supported on static images.'); + } + + var contentModes = NativeModulesDeprecated.RKUIManager.UIView.ContentMode; + var resizeMode; + if (style.resizeMode === ImageResizeMode.stretch) { + resizeMode = contentModes.ScaleToFill; + } else if (style.resizeMode === ImageResizeMode.contain) { + resizeMode = contentModes.ScaleAspectFit; + } else { // ImageResizeMode.cover or undefined + resizeMode = contentModes.ScaleAspectFill; + } + + var nativeProps = merge(this.props, { + style, + resizeMode, + tintColor: style.tintColor, + }); + + if (isStored) { + nativeProps.imageTag = source.uri; + } else { + nativeProps.src = source.uri; + } + return ; + } +}); + +var styles = StyleSheet.create({ + base: { + overflow: 'hidden', + }, +}); + +var CommonImageViewAttributes = merge(ReactIOSViewAttributes.UIView, { + accessible: true, + accessibilityLabel: true, + capInsets: {diff: insetsDiffer}, // UIEdgeInsets=UIEdgeInsetsZero + imageTag: true, + resizeMode: true, + src: true, + testID: PropTypes.string, +}); + +var RKStaticImage = createReactIOSNativeComponentClass({ + validAttributes: merge(CommonImageViewAttributes, { tintColor: true }), + uiViewClassName: 'RCTStaticImage', +}); + +var RKNetworkImage = createReactIOSNativeComponentClass({ + validAttributes: merge(CommonImageViewAttributes, { defaultImageSrc: true }), + uiViewClassName: 'RCTNetworkImageView', +}); + +module.exports = Image; diff --git a/Libraries/Components/Image/ImageResizeMode.js b/Libraries/Components/Image/ImageResizeMode.js new file mode 100644 index 0000000000..813e7016e8 --- /dev/null +++ b/Libraries/Components/Image/ImageResizeMode.js @@ -0,0 +1,33 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ImageResizeMode + */ +'use strict'; + +var keyMirror = require('keyMirror'); + +/** + * ImageResizeMode - Enum for different image resizing modes, set via + * `resizeMode` style property on `` components. + */ +var ImageResizeMode = keyMirror({ + /** + * contain - The image will be resized such that it will be completely + * visible, contained within the frame of the View. + */ + contain: null, + /** + * cover - The image will be resized such that the entire area of the view + * is covered by the image, potentially clipping parts of the image. + */ + cover: null, + /** + * stretch - The image will be stretched to fill the entire frame of the + * view without clipping. This may change the aspect ratio of the image, + * distoring it. Only supported on iOS. + */ + stretch: null, +}); + +module.exports = ImageResizeMode; diff --git a/Libraries/Components/Image/ImageSourcePropType.js b/Libraries/Components/Image/ImageSourcePropType.js new file mode 100644 index 0000000000..84eaf66294 --- /dev/null +++ b/Libraries/Components/Image/ImageSourcePropType.js @@ -0,0 +1,27 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ImageSourcePropType + * @flow + */ +'use strict'; + +var { PropTypes } = require('React'); + +var ImageSourcePropType = PropTypes.shape({ + /** + * uri - A string representing the resource identifier for the image, which + * could be an http address, a local file path, or the name of a static image + * resource (which should be wrapped in the `ix` function). + */ + uri: PropTypes.string.isRequired, + /** + * width/height - Used to store the size of the image itself, but unused by + * the component - use normal style layout properties to define the + * size of the frame. + */ + width: PropTypes.number, + height: PropTypes.number, +}); + +module.exports = ImageSourcePropType; diff --git a/Libraries/Components/Image/ImageStylePropTypes.js b/Libraries/Components/Image/ImageStylePropTypes.js new file mode 100644 index 0000000000..3c67a77958 --- /dev/null +++ b/Libraries/Components/Image/ImageStylePropTypes.js @@ -0,0 +1,45 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ImageStylePropTypes + */ +'use strict'; + +var ImageResizeMode = require('ImageResizeMode'); +var LayoutPropTypes = require('LayoutPropTypes'); +var ReactPropTypes = require('ReactPropTypes'); + +var merge = require('merge'); + +var ImageStylePropTypes = merge( + LayoutPropTypes, + { + resizeMode: ReactPropTypes.oneOf(Object.keys(ImageResizeMode)), + backgroundColor: ReactPropTypes.string, + borderColor: ReactPropTypes.string, + borderWidth: ReactPropTypes.number, + borderRadius: ReactPropTypes.number, + + // iOS-Specific style to "tint" an image. + // It changes the color of all the non-transparent pixels to the tintColor + tintColor: ReactPropTypes.string, + opacity: ReactPropTypes.number, + } +); + +// Image doesn't support padding correctly (#4841912) +var unsupportedProps = Object.keys({ + padding: null, + paddingTop: null, + paddingLeft: null, + paddingRight: null, + paddingBottom: null, + paddingVertical: null, + paddingHorizontal: null, +}); + +for (var key in unsupportedProps) { + delete ImageStylePropTypes[key]; +} + +module.exports = ImageStylePropTypes; diff --git a/Libraries/Components/Image/ix.js b/Libraries/Components/Image/ix.js new file mode 100644 index 0000000000..52cbbc4e2a --- /dev/null +++ b/Libraries/Components/Image/ix.js @@ -0,0 +1,26 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ix + */ +'use strict'; + +/** + * This function is used to mark string literals that are image paths. The + * return value is a blob of data that core image components understand how to + * render. + * + * The arguments to ix() must be string literals so that they can be parsed + * statically. + * + * @param string Image path to render + * @return object Data blob to be used by core UI components + */ +function ix(path) { + return { + uri: path, + isStatic: true, + }; +} + +module.exports = ix; diff --git a/Libraries/Components/ListView/ListView.js b/Libraries/Components/ListView/ListView.js new file mode 100644 index 0000000000..45539586be --- /dev/null +++ b/Libraries/Components/ListView/ListView.js @@ -0,0 +1,469 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ListView + */ +'use strict'; + +var ListViewDataSource = require('ListViewDataSource'); +var React = require('React'); +var RKUIManager = require('NativeModules').RKUIManager; +var ScrollView = require('ScrollView'); +var ScrollResponder = require('ScrollResponder'); +var StaticRenderer = require('StaticRenderer'); +var TimerMixin = require('TimerMixin'); + +var logError = require('logError'); +var merge = require('merge'); +var isEmpty = require('isEmpty'); + +var PropTypes = React.PropTypes; + +/** + * ListView - A core component designed for efficient display of vertically + * scrolling lists of changing data. The minimal API is to create a + * `ListViewDataSource`, populate it with a simple array of data blobs, and + * instantiate a `ListView` component with that data source and a `renderRow` + * callback which takes a blob from the data array and returns a renderable + * component. Minimal example: + * + * getInitialState: function() { + * var ds = new ListViewDataSource({rowHasChanged: (r1, r2) => r1 !== r2}); + * return { + * dataSource: ds.cloneWithRows(['row 1', 'row 2']), + * }; + * }, + * + * render: function() { + * return ( + * {rowData}} + * /> + * ); + * }, + * + * ListView also supports more advanced features, including sections with sticky + * section headers, header and footer support, callbacks on reaching the end of + * the available data (`onEndReached`) and on the set of rows that are visible + * in the device viewport change (`onChangeVisibleRows`), and several + * performance optimizations. + * + * There are a few performance operations designed to make ListView scroll + * smoothly while dynamically loading potentially very large (or conceptually + * infinite) data sets: + * + * * Only re-render changed rows - the hasRowChanged function provided to the + * data source tells the ListView if it needs to re-render a row because the + * source data has changed - see ListViewDataSource for more details. + * + * * Rate-limited row rendering - By default, only one row is rendered per + * event-loop (customizable with the `pageSize` prop). This breaks up the + * work into smaller chunks to reduce the chance of dropping frames while + * rendering rows. + * + * Check out `ListViewSimpleExample.js`, `ListViewDataSource.js`, and the Movies + * app for more info and example usage. + */ + +var DEFAULT_PAGE_SIZE = 1; +var DEFAULT_INITIAL_ROWS = 10; +var DEFAULT_SCROLL_RENDER_AHEAD = 1000; +var DEFAULT_END_REACHED_THRESHOLD = 1000; +var DEFAULT_SCROLL_CALLBACK_THROTTLE = 50; +var RENDER_INTERVAL = 20; +var SCROLLVIEW_REF = 'listviewscroll'; + +var ListView = React.createClass({ + mixins: [ScrollResponder.Mixin, TimerMixin], + + /** + * You must provide a renderRow function. If you omit any of the other render + * functions, ListView will simply skip rendering them. + * + * - renderRow(rowData, sectionID, rowID); + * - renderSectionHeader(sectionData, sectionID); + */ + propTypes: + merge( + ScrollView.PropTypes, { + dataSource: PropTypes.instanceOf(ListViewDataSource).isRequired, + /** + * (rowData, sectionID, rowID) => renderable + * Takes a data entry from the data source and its ids and should return + * a renderable component to be rendered as the row. By default the data + * is exactly what was put into the data source, but it's also possible to + * provide custom extractors. + */ + renderRow: PropTypes.func.isRequired, + /** + * How many rows to render on initial component mount. Use this to make + * it so that the first screen worth of data apears at one time instead of + * over the course of multiple frames. + */ + initialListSize: PropTypes.number, + /** + * Called when all rows have been rendered and the list has been scrolled + * to within onEndReachedThreshold of the bottom. The native scroll + * event is provided. + */ + onEndReached: PropTypes.func, + /** + * Threshold in pixels for onEndReached. + */ + onEndReachedThreshold: PropTypes.number, + /** + * Number of rows to render per event loop. + */ + pageSize: PropTypes.number, + /** + * () => renderable + * + * The header and footer are always rendered (if these props are provided) + * on every render pass. If they are expensive to re-render, wrap them + * in StaticContainer or other mechanism as appropriate. Footer is always + * at the bottom of the list, and header at the top, on every render pass. + */ + renderFooter: PropTypes.func, + renderHeader: PropTypes.func, + /** + * (sectionData, sectionID) => renderable + * + * If provided, a sticky header is rendered for this section. The sticky + * behavior means that it will scroll with the content at the top of the + * section until it reaches the top of the screen, at which point it will + * stick to the top until it is pushed off the screen by the next section + * header. + */ + renderSectionHeader: PropTypes.func, + /** + * How early to start rendering rows before they come on screen, in + * pixels. + */ + scrollRenderAheadDistance: React.PropTypes.number, + /** + * (visibleRows, changedRows) => void + * + * Called when the set of visible rows changes. `visibleRows` maps + * { sectionID: { rowID: true }} for all the visible rows, and + * `changedRows` maps { sectionID: { rowID: true | false }} for the rows + * that have changed their visibility, with true indicating visible, and + * false indicating the view has moved out of view. + */ + onChangeVisibleRows: React.PropTypes.func, + /** + * An experimental performance optimization for improving scroll perf of + * large lists, used in conjunction with overflow: 'hidden' on the row + * containers. Use at your own risk. + */ + removeClippedSubviews: React.PropTypes.bool, + }), + + /** + * Exports some data, e.g. for perf investigations or analytics. + */ + getMetrics: function() { + return { + contentHeight: this.scrollProperties.contentHeight, + totalRows: this.props.dataSource.getRowCount(), + renderedRows: this.state.curRenderedRowsCount, + visibleRows: Object.keys(this._visibleRows).length, + }; + }, + + /** + * Provides a handle to the underlying scroll responder to support operations + * such as scrollTo. + */ + getScrollResponder: function() { + return this.refs[SCROLLVIEW_REF]; + }, + + setNativeProps: function(props) { + this.refs[SCROLLVIEW_REF].setNativeProps(props); + }, + + /** + * React life cycle hooks. + */ + + getDefaultProps: function() { + return { + initialListSize: DEFAULT_INITIAL_ROWS, + pageSize: DEFAULT_PAGE_SIZE, + scrollRenderAheadDistance: DEFAULT_SCROLL_RENDER_AHEAD, + onEndReachedThreshold: DEFAULT_END_REACHED_THRESHOLD, + }; + }, + + getInitialState: function() { + return { + curRenderedRowsCount: this.props.initialListSize, + prevRenderedRowsCount: 0, + }; + }, + + componentWillMount: function() { + // this data should never trigger a render pass, so don't put in state + this.scrollProperties = { + visibleHeight: null, + contentHeight: null, + offsetY: 0 + }; + this._childFrames = []; + this._visibleRows = {}; + }, + + componentDidMount: function() { + // do this in animation frame until componentDidMount actually runs after + // the component is laid out + this.requestAnimationFrame(() => { + this._measureAndUpdateScrollProps(); + this.setInterval(this._renderMoreRowsIfNeeded, RENDER_INTERVAL); + }); + }, + + componentWillReceiveProps: function(nextProps) { + if (this.props.dataSource !== nextProps.dataSource) { + this.setState({prevRenderedRowsCount: 0}); + } + }, + + render: function() { + var bodyComponents = []; + + var dataSource = this.props.dataSource; + var allRowIDs = dataSource.rowIdentities; + var rowCount = 0; + var sectionHeaderIndices = []; + + var header = this.props.renderHeader && this.props.renderHeader(); + var footer = this.props.renderFooter && this.props.renderFooter(); + var totalIndex = header ? 1 : 0; + + for (var sectionIdx = 0; sectionIdx < allRowIDs.length; sectionIdx++) { + var sectionID = dataSource.sectionIdentities[sectionIdx]; + var rowIDs = allRowIDs[sectionIdx]; + if (rowIDs.length === 0) { + continue; + } + + if (this.props.renderSectionHeader) { + var shouldUpdateHeader = rowCount >= this.state.prevRenderedRowsCount && + dataSource.sectionHeaderShouldUpdate(sectionIdx); + bodyComponents.push( + + ); + sectionHeaderIndices.push(totalIndex++); + } + + for (var rowIdx = 0; rowIdx < rowIDs.length; rowIdx++) { + var rowID = rowIDs[rowIdx]; + var comboID = sectionID + rowID; + var shouldUpdateRow = rowCount >= this.state.prevRenderedRowsCount && + dataSource.rowShouldUpdate(sectionIdx, rowIdx); + var row = + ; + bodyComponents.push(row); + totalIndex++; + if (++rowCount === this.state.curRenderedRowsCount) { + break; + } + } + if (rowCount >= this.state.curRenderedRowsCount) { + break; + } + } + + var props = merge( + this.props, { + onScroll: this._onScroll, + stickyHeaderIndices: sectionHeaderIndices, + } + ); + if (!props.throttleScrollCallbackMS) { + props.throttleScrollCallbackMS = DEFAULT_SCROLL_CALLBACK_THROTTLE; + } + + return ( + + {header} + {bodyComponents} + {footer} + + ); + }, + + /** + * Private methods + */ + + _measureAndUpdateScrollProps: function() { + RKUIManager.measureLayout( + this.refs[SCROLLVIEW_REF].getInnerViewNode(), + this.refs[SCROLLVIEW_REF].getNodeHandle(), + logError, + this._setScrollContentHeight + ); + RKUIManager.measureLayoutRelativeToParent( + this.refs[SCROLLVIEW_REF].getNodeHandle(), + logError, + this._setScrollVisibleHeight + ); + }, + + _setScrollContentHeight: function(left, top, width, height) { + this.scrollProperties.contentHeight = height; + }, + + _setScrollVisibleHeight: function(left, top, width, height) { + this.scrollProperties.visibleHeight = height; + this._updateVisibleRows(); + }, + + _renderMoreRowsIfNeeded: function() { + if (this.scrollProperties.contentHeight === null || + this.scrollProperties.visibleHeight === null || + this.state.curRenderedRowsCount === this.props.dataSource.getRowCount()) { + return; + } + + var distanceFromEnd = this._getDistanceFromEnd(this.scrollProperties); + if (distanceFromEnd < this.props.scrollRenderAheadDistance) { + this._pageInNewRows(); + } + }, + + _pageInNewRows: function() { + var rowsToRender = Math.min( + this.state.curRenderedRowsCount + this.props.pageSize, + this.props.dataSource.getRowCount() + ); + this.setState( + { + prevRenderedRowsCount: this.state.curRenderedRowsCount, + curRenderedRowsCount: rowsToRender + }, + () => { + this._measureAndUpdateScrollProps(); + this.setState({ + prevRenderedRowsCount: this.state.curRenderedRowsCount, + }); + } + ); + }, + + _getDistanceFromEnd: function(scrollProperties) { + return scrollProperties.contentHeight - + scrollProperties.visibleHeight - + scrollProperties.offsetY; + }, + + _updateVisibleRows: function(e) { + if (!this.props.onChangeVisibleRows) { + return; // No need to compute visible rows if there is no callback + } + var updatedFrames = e && e.nativeEvent.updatedChildFrames; + if (updatedFrames) { + updatedFrames.forEach((frame) => { + this._childFrames[frame.index] = merge(frame); + }); + } + var dataSource = this.props.dataSource; + var visibleTop = this.scrollProperties.offsetY; + var visibleBottom = visibleTop + this.scrollProperties.visibleHeight; + var allRowIDs = dataSource.rowIdentities; + + var header = this.props.renderHeader && this.props.renderHeader(); + var totalIndex = header ? 1 : 0; + var visibilityChanged = false; + var changedRows = {}; + for (var sectionIdx = 0; sectionIdx < allRowIDs.length; sectionIdx++) { + var rowIDs = allRowIDs[sectionIdx]; + if (rowIDs.length === 0) { + continue; + } + var sectionID = dataSource.sectionIdentities[sectionIdx]; + if (this.props.renderSectionHeader) { + totalIndex++; + } + var visibleSection = this._visibleRows[sectionID]; + if (!visibleSection) { + visibleSection = {}; + } + for (var rowIdx = 0; rowIdx < rowIDs.length; rowIdx++) { + var rowID = rowIDs[rowIdx]; + var frame = this._childFrames[totalIndex]; + totalIndex++; + if (!frame) { + break; + } + var rowVisible = visibleSection[rowID]; + var top = frame.y; + var bottom = top + frame.height; + if (top > visibleBottom || bottom < visibleTop) { + if (rowVisible) { + visibilityChanged = true; + delete visibleSection[rowID]; + if (!changedRows[sectionID]) { + changedRows[sectionID] = {}; + } + changedRows[sectionID][rowID] = false; + } + } else if (!rowVisible) { + visibilityChanged = true; + visibleSection[rowID] = true; + if (!changedRows[sectionID]) { + changedRows[sectionID] = {}; + } + changedRows[sectionID][rowID] = true; + } + } + if (!isEmpty(visibleSection)) { + this._visibleRows[sectionID] = visibleSection; + } else if (this._visibleRows[sectionID]) { + delete this._visibleRows[sectionID]; + } + } + visibilityChanged && this.props.onChangeVisibleRows(this._visibleRows, changedRows); + }, + + _onScroll: function(e) { + this.scrollProperties.visibleHeight = e.nativeEvent.layoutMeasurement.height; + this.scrollProperties.contentHeight = e.nativeEvent.contentSize.height; + this.scrollProperties.offsetY = e.nativeEvent.contentOffset.y; + this._updateVisibleRows(e); + var nearEnd = this._getDistanceFromEnd(this.scrollProperties) < this.props.onEndReachedThreshold; + if (nearEnd && + this.props.onEndReached && + this.scrollProperties.contentHeight !== this._sentEndForContentHeight && + this.state.curRenderedRowsCount === this.props.dataSource.getRowCount()) { + this._sentEndForContentHeight = this.scrollProperties.contentHeight; + this.props.onEndReached(e); + } else { + this._renderMoreRowsIfNeeded(); + } + + this.props.onScroll && this.props.onScroll(e); + }, +}); + +module.exports = ListView; diff --git a/Libraries/Components/ListView/ListViewDataSource.js b/Libraries/Components/ListView/ListViewDataSource.js new file mode 100644 index 0000000000..3be1094432 --- /dev/null +++ b/Libraries/Components/ListView/ListViewDataSource.js @@ -0,0 +1,379 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ListViewDataSource + * @typechecks + * @flow + */ +'use strict'; + +var invariant = require('invariant'); +var isEmpty = require('isEmpty'); +var warning = require('warning'); + +/** + * ListViewDataSource - Provides efficient data processing and access to the + * ListView component. A ListViewDataSource is created with functions for + * extracting data from the input blob, and comparing elements (with default + * implementations for convenience). The input blob can be as simple as an + * array of strings, or an object with rows nested inside section objects. + * + * To update the data in the datasource, use `cloneWithRows` (or + * `cloneWithRowsAndSections` if you care about sections). The data in the + * data source is immutable, so you can't modify it directly. The clone methods + * suck in the new data and compute a diff for each row so ListView knows + * whether to re-render it or not. + * + * In this example, a component receives data in chunks, handled by + * `_onDataArrived`, which concats the new data onto the old data and updates the + * data source. We use `concat` to create a new array - mutating `this._data`, + * e.g. with `this._data.push(newRowData)`, would be an error. `_rowHasChanged` + * understands the shape of the row data and knows how to efficiently compare + * it. + * + * getInitialState: function() { + * var ds = new ListViewDataSource({rowHasChanged: this._rowHasChanged}); + * return {ds}; + * }, + * _onDataArrived(newData) { + * this._data = this._data.concat(newData); + * this.setState({ + * ds: this.state.ds.cloneWithRows(this._data) + * }); + * } + */ + +function defaultGetRowData( + dataBlob: any, + sectionID: number | string, + rowID: number | string +): any { + return dataBlob[sectionID][rowID]; +} + +function defaultGetSectionHeaderData( + dataBlob: any, + sectionID: number | string +): any { + return dataBlob[sectionID]; +} + +type differType = (data1: any, data2: any) => bool; + +type ParamType = { + rowHasChanged: differType; + getRowData: ?typeof defaultGetRowData; + sectionHeaderHasChanged: ?differType; + getSectionHeaderData: ?typeof defaultGetSectionHeaderData; +} + +class ListViewDataSource { + + /** + * @param {ParamType} params + * + * You can provide custom extraction and 'hasChanged' functions for section + * headers and rows. If absent, data will be extracted with the + * `defaultGetRowData` and `defaultGetSectionHeaderData` functions. + * + * - getRowData(dataBlob, sectionID, rowID); + * - getSectionHeaderData(dataBlob, sectionID); + * - rowHasChanged(prevRowData, nextRowData); + * - sectionHeaderHasChanged(prevSectionData, nextSectionData); + */ + constructor(params: ParamType) { + invariant( + params && typeof params.rowHasChanged === 'function', + 'Must provide a rowHasChanged function.' + ); + this._rowHasChanged = params.rowHasChanged; + this._getRowData = params.getRowData || defaultGetRowData; + this._sectionHeaderHasChanged = params.sectionHeaderHasChanged; + this._getSectionHeaderData = + params.getSectionHeaderData || defaultGetSectionHeaderData; + + this._dataBlob = null; + this._dirtyRows = []; + this._dirtySections = []; + this._cachedRowCount = 0; + + // These two private variables are accessed by outsiders because ListView + // uses them to iterate over the data in this class. + this.rowIdentities = []; + this.sectionIdentities = []; + } + + /** + * @param {object} dataBlob -- This is an arbitrary blob of data. An extractor + * function was defined at construction time. The default extractor assumes + * the data is a plain array or keyed object. + */ + cloneWithRows( + dataBlob: Array | {[key: string]: any}, + rowIdentities: ?Array + ): ListViewDataSource { + var rowIds = rowIdentities ? [rowIdentities] : null; + if (!this._sectionHeaderHasChanged) { + this._sectionHeaderHasChanged = () => false; + } + return this.cloneWithRowsAndSections({s1: dataBlob}, ['s1'], rowIds); + } + + /** + * @param {object} dataBlob -- This is an arbitrary blob of data. An extractor + * function was defined at construction time. The default extractor assumes + * the data is a nested array or keyed object of the form: + * + * { sectionID_1: { rowID_1: , ... }, ... } + * + * or + * + * [ [ , , ... ], ... ] + * + * @param {array} sectionIdentities -- This is an array of identifiers for + * sections. ie. ['s1', 's2', ...]. If not provided, it's assumed that the + * keys of dataBlob are the section identities. + * @param {array} rowIdentities -- This is a 2D array of identifiers for rows. + * ie. [['a1', 'a2'], ['b1', 'b2', 'b3'], ...]. If not provided, it's + * assumed that the keys of the section data are the row identities. + * + * Note: this returns a new object! + */ + cloneWithRowsAndSections( + dataBlob: any, + sectionIdentities: ?Array, + rowIdentities: ?Array> + ): ListViewDataSource { + invariant( + typeof this._sectionHeaderHasChanged === 'function', + 'Must provide a sectionHeaderHasChanged function with section data.' + ); + var newSource = new ListViewDataSource({ + getRowData: this._getRowData, + getSectionHeaderData: this._getSectionHeaderData, + rowHasChanged: this._rowHasChanged, + sectionHeaderHasChanged: this._sectionHeaderHasChanged, + }); + newSource._dataBlob = dataBlob; + if (sectionIdentities) { + newSource.sectionIdentities = sectionIdentities; + } else { + newSource.sectionIdentities = Object.keys(dataBlob); + } + if (rowIdentities) { + newSource.rowIdentities = rowIdentities; + } else { + newSource.rowIdentities = []; + newSource.sectionIdentities.forEach((sectionID) => { + newSource.rowIdentities.push(Object.keys(dataBlob[sectionID])); + }); + } + newSource._cachedRowCount = countRows(newSource.rowIdentities); + + newSource._calculateDirtyArrays( + this._dataBlob, + this.sectionIdentities, + this.rowIdentities + ); + + return newSource; + } + + getRowCount(): number { + return this._cachedRowCount; + } + + /** + * @param {number} sectionIndex + * @param {number} rowIndex + * + * Returns if the row is dirtied and needs to be rerendered + */ + rowShouldUpdate(sectionIndex: number, rowIndex: number): bool { + var needsUpdate = this._dirtyRows[sectionIndex][rowIndex]; + warning(needsUpdate !== undefined, + 'missing dirtyBit for section, row: ' + sectionIndex + ', ' + rowIndex); + return needsUpdate; + } + + /** + * @param {number} sectionIndex + * @param {number} rowIndex + * + * Gets the data required to render the row. + */ + getRowData(sectionIndex: number, rowIndex: number): any { + var sectionID = this.sectionIdentities[sectionIndex]; + var rowID = this.rowIdentities[sectionIndex][rowIndex]; + warning( + sectionID !== undefined && rowID !== undefined, + 'rendering invalid section, row: ' + sectionIndex + ', ' + rowIndex + ); + return this._getRowData(this._dataBlob, sectionID, rowID); + } + + /** + * @param {number} index + * + * Gets the rowID at index provided if the dataSource arrays were flattened + */ + getRowIDForFlatIndex(index: number): string { + var accessIndex = index; + for (var ii = 0; ii < this.sectionIdentities.length; ii++) { + if (accessIndex >= this.rowIdentities[ii].length) { + accessIndex -= this.rowIdentities[ii].length; + } else { + return this.rowIdentities[ii][accessIndex]; + } + } + } + + /** + * @param {number} index + * + * Gets the sectionID at index provided if the dataSource arrays were flattened + */ + getSectionIDForFlatIndex(index: number): string { + var accessIndex = index; + for (var ii = 0; ii < this.sectionIdentities.length; ii++) { + if (accessIndex >= this.rowIdentities[ii].length) { + accessIndex -= this.rowIdentities[ii].length; + } else { + return this.sectionIdentities[ii]; + } + } + } + + /** + * Returns an array containing the number of rows in each section + */ + getSectionLengths(): Array { + var results = []; + for (var ii = 0; ii < this.sectionIdentities.length; ii++) { + results.push(this.rowIdentities[ii].length); + } + return results; + } + + /** + * @param {number} sectionIndex + * + * Returns if the section header is dirtied and needs to be rerendered + */ + sectionHeaderShouldUpdate(sectionIndex: number): bool { + var needsUpdate = this._dirtySections[sectionIndex]; + warning(needsUpdate !== undefined, + 'missing dirtyBit for section: ' + sectionIndex); + return needsUpdate; + } + + /** + * @param {number} sectionIndex + * + * Gets the data required to render the section header + */ + getSectionHeaderData(sectionIndex: number): any { + if (!this._getSectionHeaderData) { + return null; + } + var sectionID = this.sectionIdentities[sectionIndex]; + warning(sectionID !== undefined, + 'renderSection called on invalid section: ' + sectionIndex); + return this._getSectionHeaderData(this._dataBlob, sectionID); + } + + /** + * Private members and methods. + */ + + _getRowData: typeof defaultGetRowData; + _getSectionHeaderData: typeof defaultGetSectionHeaderData; + _rowHasChanged: differType; + _sectionHeaderHasChanged: ?differType; + + _dataBlob: any; + _dirtyRows: Array>; + _dirtySections: Array; + _cachedRowCount: number; + + // These two 'protected' variables are accessed by ListView to iterate over + // the data in this class. + rowIdentities: Array>; + sectionIdentities: Array; + + _calculateDirtyArrays( + prevDataBlob: any, + prevSectionIDs: Array, + prevRowIDs: Array> + ): void { + // construct a hashmap of the existing (old) id arrays + var prevSectionsHash = keyedDictionaryFromArray(prevSectionIDs); + var prevRowsHash = {}; + for (var ii = 0; ii < prevRowIDs.length; ii++) { + var sectionID = prevSectionIDs[ii]; + warning( + !prevRowsHash[sectionID], + 'SectionID appears more than once: ' + sectionID + ); + prevRowsHash[sectionID] = keyedDictionaryFromArray(prevRowIDs[ii]); + } + + // compare the 2 identity array and get the dirtied rows + this._dirtySections = []; + this._dirtyRows = []; + + var dirty; + for (var sIndex = 0; sIndex < this.sectionIdentities.length; sIndex++) { + var sectionID = this.sectionIdentities[sIndex]; + // dirty if the sectionHeader is new or _sectionHasChanged is true + dirty = !prevSectionsHash[sectionID]; + var sectionHeaderHasChanged = this._sectionHeaderHasChanged; + if (!dirty && sectionHeaderHasChanged) { + dirty = sectionHeaderHasChanged( + this._getSectionHeaderData(prevDataBlob, sectionID), + this._getSectionHeaderData(this._dataBlob, sectionID) + ); + } + this._dirtySections.push(!!dirty); + + this._dirtyRows[sIndex] = []; + for (var rIndex = 0; rIndex < this.rowIdentities[sIndex].length; rIndex++) { + var rowID = this.rowIdentities[sIndex][rIndex]; + // dirty if the section is new, row is new or _rowHasChanged is true + dirty = + !prevSectionsHash[sectionID] || + !prevRowsHash[sectionID][rowID] || + this._rowHasChanged( + this._getRowData(prevDataBlob, sectionID, rowID), + this._getRowData(this._dataBlob, sectionID, rowID) + ); + this._dirtyRows[sIndex].push(!!dirty); + } + } + } +} + +function countRows(allRowIDs) { + var totalRows = 0; + for (var sectionIdx = 0; sectionIdx < allRowIDs.length; sectionIdx++) { + var rowIDs = allRowIDs[sectionIdx]; + totalRows += rowIDs.length; + } + return totalRows; +} + +function keyedDictionaryFromArray(arr) { + if (isEmpty(arr)) { + return {}; + } + var result = {}; + for (var ii = 0; ii < arr.length; ii++) { + var key = arr[ii]; + warning(!result[key], 'Value appears more than once in array: ' + key); + result[key] = true; + } + return result; +} + + +module.exports = ListViewDataSource; diff --git a/Libraries/Components/Navigation/NavigatorIOS.ios.js b/Libraries/Components/Navigation/NavigatorIOS.ios.js new file mode 100644 index 0000000000..efd983203c --- /dev/null +++ b/Libraries/Components/Navigation/NavigatorIOS.ios.js @@ -0,0 +1,563 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule NavigatorIOS + */ +'use strict'; + +var EventEmitter = require('EventEmitter'); +var React = require('React'); +var ReactIOSViewAttributes = require('ReactIOSViewAttributes'); +var { RKUIManager } = require('NativeModules'); +var StyleSheet = require('StyleSheet'); +var StaticContainer = require('StaticContainer.react'); +var View = require('View'); + +var createReactIOSNativeComponentClass = + require('createReactIOSNativeComponentClass'); +var invariant = require('invariant'); +var logError = require('logError'); +var merge = require('merge'); + +/** + * NavigatorIOS wraps UIKit navigation and allows you to add back-swipe + * functionality across your app. + * + * See UIExplorerApp and NavigatorIOSExample for a full example + * + * ======================= NavigatorIOS Routes ================================ + * A route is an object used to describe each page in the navigator. The first + * route is provided to NavigatorIOS as `initialRoute`: + * + * ``` + * render: function() { + * return ( + * + * ); + * }, + * ``` + * + * Now MyView will be rendered by the navigator. It will recieve the route + * object in the `route` prop, a navigator, and all of the props specified in + * `passProps`. + * + * See the initialRoute propType for a complete definition of a route. + * + * ====================== NavigatorIOS Navigator ============================== + * A `navigator` is an object of navigation functions that a view can call. It + * is passed as a prop to any component rendered by NavigatorIOS. + * + * ``` + * var MyView = React.createClass({ + * _handleBackButtonPress: function() { + * this.props.navigator.pop(); + * }, + * _handleNextButtonPress: function() { + * this.props.navigator.push(nextRoute); + * }, + * ... + * }); + * ``` + * + * A navigation object contains the following functions: + * - `push(route)` - Navigate forward to a new route + * - `pop()` - Go back one page + * - `popN(n)` - Go back N pages at once. When N=1, behavior matches `pop()` + * - `replace(route)` - Replace the route for the current page and immediately + * load the view for the new route + * - `replacePrevious(route)` - Replace the route/view for the previous page + * - `replacePreviousAndPop(route)` - Replaces the previous route/view and + * transitions back to it + * - `resetTo(route)` - Replaces the top item and popToTop + * - `popToRoute(route)` - Go back to the item for a particular route object + * - `popToTop()` - Go back to the top item + * + * Navigator functions are also available on the NavigatorIOS component: + * + * ``` + * var MyView = React.createClass({ + * _handleNavigationRequest: function() { + * this.refs.nav.push(otherRoute); + * }, + * render: () => ( + * + * ), + * }); + * ``` + * + */ +var TRANSITIONER_REF = 'transitionerRef'; + +var PropTypes = React.PropTypes; + +var __uid = 0; +function getuid() { + return __uid++; +} + +var RKNavigator = createReactIOSNativeComponentClass({ + validAttributes: merge(ReactIOSViewAttributes.UIView, { + requestedTopOfStack: true + }), + uiViewClassName: 'RCTNavigator', +}); + +var RKNavigatorItem = createReactIOSNativeComponentClass({ + validAttributes: { + // TODO: Remove or fix the attributes that are not fully functional. + // NavigatorIOS does not use them all, because some are problematic + title: true, + barTintColor: true, + rightButtonTitle: true, + onNavRightButtonTap: true, + tintColor: true, + backButtonTitle: true, + titleTextColor: true, + style: true, + }, + uiViewClassName: 'RCTNavItem', +}); + +var NavigatorTransitionerIOS = React.createClass({ + requestSchedulingNavigation: function(cb) { + RKUIManager.requestSchedulingJavaScriptNavigation( + this.getNodeHandle(), + logError, + cb + ); + }, + + render: function() { + return ( + + ); + }, +}); + + +/** + * Think of `` as simply a component that renders an + * `RKNavigator`, and moves the `RKNavigator`'s `requestedTopOfStack` pointer + * forward and backward. The `RKNavigator` interprets changes in + * `requestedTopOfStack` to be pushes and pops of children that are rendered. + * `` always ensures that whenever the `requestedTopOfStack` + * pointer is moved, that we've also rendered enough children so that the + * `RKNavigator` can carry out the push/pop with those children. + * `` also removes children that will no longer be needed + * (after the pop of a child has been fully completed/animated out). + */ +var NavigatorIOS = React.createClass({ + + propTypes: { + + /** + * NavigatorIOS uses "route" objects to identify child views, their props, + * and navigation bar configuration. "push" and all the other navigation + * operations expect routes to be like this: + */ + initialRoute: PropTypes.shape({ + /** + * The React Class to render for this route + */ + component: PropTypes.func.isRequired, + + /** + * The title displayed in the nav bar and back button for this route + */ + title: PropTypes.string.isRequired, + + /** + * Specify additional props passed to the component. NavigatorIOS will + * automatically provide "route" and "navigator" components + */ + passProps: PropTypes.object, + + /** + * If set, the left header button will appear with this name. Note that + * this doesn't apply for the header of the current view, but the ones + * of the views that are pushed afterward. + */ + backButtonTitle: PropTypes.string, + + /** + * If set, the right header button will appear with this name + */ + rightButtonTitle: PropTypes.string, + + /** + * Called when the right header button is pressed + */ + onRightButtonPress: PropTypes.func, + + /** + * Styles for the navigation item containing the component + */ + wrapperStyle: View.stylePropType, + + }).isRequired, + + /** + * The default wrapper style for components in the navigator. + * A common use case is to set the backgroundColor for every page + */ + itemWrapperStyle: View.stylePropType, + + /** + * The color used for buttons in the navigation bar + */ + tintColor: PropTypes.string, + + }, + + componentWillMount: function() { + // Precompute a pack of callbacks that's frequently generated and passed to + // instances. + this.navigator = { + push: this.push, + pop: this.pop, + popN: this.popN, + replace: this.replace, + replacePrevious: this.replacePrevious, + replacePreviousAndPop: this.replacePreviousAndPop, + resetTo: this.resetTo, + popToRoute: this.popToRoute, + popToTop: this.popToTop, + }; + }, + + getInitialState: function() { + return { + idStack: [getuid()], + routeStack: [this.props.initialRoute], + // The navigation index that we wish to push/pop to. + requestedTopOfStack: 0, + // The last index that native has sent confirmation of completed push/pop + // for. At this point, we can discard any views that are beyond the + // `requestedTopOfStack`. A value of `null` means we have not received + // any confirmation, ever. We may receive an `observedTopOfStack` without + // ever requesting it - native can instigate pops of its own with the + // backswipe gesture. + observedTopOfStack: 0, + progress: 1, + fromIndex: 0, + toIndex: 0, + // Whether or not we are making a navigator request to push/pop. (Used + // for performance optimization). + makingNavigatorRequest: false, + // Whether or not we are updating children of navigator and if so (not + // `null`) which index marks the beginning of all updates. Used for + // performance optimization. + updatingAllIndicesAtOrBeyond: 0, + }; + }, + + _handleFocusRequest: function(item) { + if (this.state.makingNavigatorRequest) { + this._toFocusOnNavigationComplete = item; + } else { + this._getFocusEmitter().emit('focus', item); + } + }, + + _getFocusEmitter: function() { + if (!this._focusEmitter) { + this._focusEmitter = new EventEmitter(); + } + return this._focusEmitter; + }, + + getChildContext: function() { + return { + onFocusRequested: this._handleFocusRequest, + focusEmitter: this._getFocusEmitter(), + }; + }, + + childContextTypes: { + onFocusRequested: React.PropTypes.func, + focusEmitter: React.PropTypes.instanceOf(EventEmitter), + }, + + _tryLockNavigator: function(cb) { + this.refs[TRANSITIONER_REF].requestSchedulingNavigation( + (acquiredLock) => acquiredLock && cb() + ); + }, + + _handleNavigatorStackChanged: function(e) { + var newObservedTopOfStack = e.nativeEvent.stackLength - 1; + invariant( + newObservedTopOfStack <= this.state.requestedTopOfStack, + 'No navigator item should be pushed without JS knowing about it %s %s', newObservedTopOfStack, this.state.requestedTopOfStack + ); + var wasWaitingForConfirmation = + this.state.requestedTopOfStack !== this.state.observedTopOfStack; + if (wasWaitingForConfirmation) { + invariant( + newObservedTopOfStack === this.state.requestedTopOfStack, + 'If waiting for observedTopOfStack to reach requestedTopOfStack, ' + + 'the only valid observedTopOfStack should be requestedTopOfStack.' + ); + } + // Mark the most recent observation regardless of if we can lock the + // navigator. `observedTopOfStack` merely represents what we've observed + // and this first `setState` is only executed to update debugging + // overlays/navigation bar. + // Also reset progress, toIndex, and fromIndex as they might not end + // in the correct states for a two possible reasons: + // Progress isn't always 0 or 1 at the end, the system rounds + // If the Navigator is offscreen these values won't be updated + // TOOD: Revisit this decision when no longer relying on native navigator. + var nextState = { + observedTopOfStack: newObservedTopOfStack, + makingNavigatorRequest: false, + updatingAllIndicesAtOrBeyond: null, + progress: 1, + toIndex: newObservedTopOfStack, + fromIndex: newObservedTopOfStack, + }; + this.setState(nextState, this._eliminateUnneededChildren); + }, + + _eliminateUnneededChildren: function() { + // Updating the indices that we're deleting and that's all. (Truth: Nothing + // even uses the indices in this case, but let's make this describe the + // truth anyways). + var updatingAllIndicesAtOrBeyond = + this.state.routeStack.length > this.state.observedTopOfStack + 1 ? + this.state.observedTopOfStack + 1 : + null; + this.setState({ + idStack: this.state.idStack.slice(0, this.state.observedTopOfStack + 1), + routeStack: this.state.routeStack.slice(0, this.state.observedTopOfStack + 1), + // Now we rerequest the top of stack that we observed. + requestedTopOfStack: this.state.observedTopOfStack, + makingNavigatorRequest: true, + updatingAllIndicesAtOrBeyond: updatingAllIndicesAtOrBeyond, + }); + }, + + push: function(route) { + invariant(!!route, 'Must supply route to push'); + // Make sure all previous requests are caught up first. Otherwise reject. + if (this.state.requestedTopOfStack === this.state.observedTopOfStack) { + this._tryLockNavigator(() => { + var nextStack = this.state.routeStack.concat([route]); + var nextIDStack = this.state.idStack.concat([getuid()]); + this.setState({ + // We have to make sure that we've also supplied enough views to + // satisfy our request to adjust the `requestedTopOfStack`. + idStack: nextIDStack, + routeStack: nextStack, + requestedTopOfStack: nextStack.length - 1, + makingNavigatorRequest: true, + updatingAllIndicesAtOrBeyond: nextStack.length - 1, + }); + }); + } + }, + + popN: function(n) { + if (n === 0) { + return; + } + // Make sure all previous requests are caught up first. Otherwise reject. + if (this.state.requestedTopOfStack === this.state.observedTopOfStack) { + if (this.state.requestedTopOfStack > 0) { + this._tryLockNavigator(() => { + invariant( + this.state.requestedTopOfStack - n >= 0, + 'Cannot pop below 0' + ); + this.setState({ + requestedTopOfStack: this.state.requestedTopOfStack - n, + makingNavigatorRequest: true, + // Not actually updating the indices yet until we get the native + // `onNavigationComplete`. + updatingAllIndicesAtOrBeyond: null, + }); + }); + } + } + }, + + pop: function() { + this.popN(1); + }, + + /** + * Replace a route in the navigation stack. + * + * `index` specifies the route in the stack that should be replaced. + * If it's negative, it counts from the back. + */ + replaceAtIndex: function(route, index) { + invariant(!!route, 'Must supply route to replace'); + if (index < 0) { + index += this.state.routeStack.length; + } + + if (this.state.routeStack.length <= index) { + return; + } + + // I don't believe we need to lock for a replace since there's no + // navigation actually happening + var nextIDStack = this.state.idStack.slice(); + var nextRouteStack = this.state.routeStack.slice(); + nextIDStack[index] = getuid(); + nextRouteStack[index] = route; + + this.setState({ + idStack: nextIDStack, + routeStack: nextRouteStack, + makingNavigatorRequest: false, + updatingAllIndicesAtOrBeyond: index, + }); + }, + + /** + * Replaces the top of the navigation stack. + */ + replace: function(route) { + this.replaceAtIndex(route, -1); + }, + + /** + * Replace the current route's parent. + */ + replacePrevious: function(route) { + this.replaceAtIndex(route, -2); + }, + + popToTop: function() { + this.popToRoute(this.state.routeStack[0]); + }, + + popToRoute: function(route) { + var indexOfRoute = this.state.routeStack.indexOf(route); + invariant( + indexOfRoute !== -1, + 'Calling pop to route for a route that doesn\'t exist!' + ); + var numToPop = this.state.routeStack.length - indexOfRoute - 1; + this.popN(numToPop); + }, + + replacePreviousAndPop: function(route) { + // Make sure all previous requests are caught up first. Otherwise reject. + if (this.state.requestedTopOfStack !== this.state.observedTopOfStack) { + return; + } + if (this.state.routeStack.length < 2) { + return; + } + this._tryLockNavigator(() => { + this.replacePrevious(route); + this.setState({ + requestedTopOfStack: this.state.requestedTopOfStack - 1, + makingNavigatorRequest: true, + }); + }); + }, + + resetTo: function(route) { + invariant(!!route, 'Must supply route to push'); + // Make sure all previous requests are caught up first. Otherwise reject. + if (this.state.requestedTopOfStack !== this.state.observedTopOfStack) { + return; + } + this.replaceAtIndex(route, 0); + this.popToRoute(route); + }, + + handleNavigationComplete: function(e) { + if (this._toFocusOnNavigationComplete) { + this._getFocusEmitter().emit('focus', this._toFocusOnNavigationComplete); + this._toFocusOnNavigationComplete = null; + } + this._handleNavigatorStackChanged(e); + }, + + _routeToStackItem: function(route, i) { + var Component = route.component; + var shouldUpdateChild = this.state.updatingAllIndicesAtOrBeyond !== null && + this.state.updatingAllIndicesAtOrBeyond >= i; + + return ( + + + + + + ); + }, + + renderNavigationStackItems: function() { + var shouldRecurseToNavigator = + this.state.makingNavigatorRequest || + this.state.updatingAllIndicesAtOrBeyond !== null; + // If not recursing update to navigator at all, may as well avoid + // computation of navigator children. + var items = shouldRecurseToNavigator ? + this.state.routeStack.map(this._routeToStackItem) : null; + return ( + + + {items} + + + ); + }, + + render: function() { + return ( + + {this.renderNavigationStackItems()} + + ); + } +}); + +var styles = StyleSheet.create({ + stackItem: { + backgroundColor: 'white', + overflow: 'hidden', + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }, + transitioner: { + flex: 1, + }, +}); + +module.exports = NavigatorIOS; diff --git a/Libraries/Components/ScrollResponder.js b/Libraries/Components/ScrollResponder.js new file mode 100644 index 0000000000..4221722bd4 --- /dev/null +++ b/Libraries/Components/ScrollResponder.js @@ -0,0 +1,458 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ScrollResponder + */ +'use strict'; + +var NativeModules = require('NativeModules'); +var NativeModulesDeprecated = require('NativeModulesDeprecated'); +var RCTDeviceEventEmitter = require('RCTDeviceEventEmitter'); +var Subscribable = require('Subscribable'); +var TextInputState = require('TextInputState'); + +var RKUIManager = NativeModules.RKUIManager; +var RKUIManagerDeprecated = NativeModulesDeprecated.RKUIManager; +var RKScrollViewConsts = RKUIManager.RCTScrollView.Constants; + +var warning = require('warning'); + +/** + * Mixin that can be integrated in order to handle scrolling that plays well + * with `ResponderEventPlugin`. Integrate with your platform specific scroll + * views, or even your custom built (every-frame animating) scroll views so that + * all of these systems play well with the `ResponderEventPlugin`. + * + * iOS scroll event timing nuances: + * =============================== + * + * + * Scrolling without bouncing, if you touch down: + * ------------------------------- + * + * 1. `onMomentumScrollBegin` (when animation begins after letting up) + * ... physical touch starts ... + * 2. `onTouchStartCapture` (when you press down to stop the scroll) + * 3. `onTouchStart` (same, but bubble phase) + * 4. `onResponderRelease` (when lifting up - you could pause forever before * lifting) + * 5. `onMomentumScrollEnd` + * + * + * Scrolling with bouncing, if you touch down: + * ------------------------------- + * + * 1. `onMomentumScrollBegin` (when animation begins after letting up) + * ... bounce begins ... + * ... some time elapses ... + * ... physical touch during bounce ... + * 2. `onMomentumScrollEnd` (Makes no sense why this occurs first during bounce) + * 3. `onTouchStartCapture` (immediately after `onMomentumScrollEnd`) + * 4. `onTouchStart` (same, but bubble phase) + * 5. `onTouchEnd` (You could hold the touch start for a long time) + * 6. `onMomentumScrollBegin` (When releasing the view starts bouncing back) + * + * So when we receive an `onTouchStart`, how can we tell if we are touching + * *during* an animation (which then causes the animation to stop)? The only way + * to tell is if the `touchStart` occurred immediately after the + * `onMomentumScrollEnd`. + * + * This is abstracted out for you, so you can just call this.scrollResponderIsAnimating() if + * necessary + * + * `ScrollResponder` also includes logic for blurring a currently focused input + * if one is focused while scrolling. The `ScrollResponder` is a natural place + * to put this logic since it can support not dismissing the keyboard while + * scrolling, unless a recognized "tap"-like gesture has occurred. + * + * The public lifecycle API includes events for keyboard interaction, responder + * interaction, and scrolling (among others). The keyboard callbacks + * `onKeyboardWill/Did/*` are *global* events, but are invoked on scroll + * responder's props so that you can guarantee that the scroll responder's + * internal state has been updated accordingly (and deterministically) by + * the time the props callbacks are invoke. Otherwise, you would always wonder + * if the scroll responder is currently in a state where it recognizes new + * keyboard positions etc. If coordinating scrolling with keyboard movement, + * *always* use these hooks instead of listening to your own global keyboard + * events. + * + * Public keyboard lifecycle API: (props callbacks) + * + * Standard Keyboard Appearance Sequence: + * + * this.props.onKeyboardWillShow + * this.props.onKeyboardDidShow + * + * `onScrollResponderKeyboardDismissed` will be invoked if an appropriate + * tap inside the scroll responder's scrollable region was responsible + * for the dismissal of the keyboard. There are other reasons why the + * keyboard could be dismissed. + * + * this.props.onScrollResponderKeyboardDismissed + * + * Standard Keyboard Hide Sequence: + * + * this.props.onKeyboardWillHide + * this.props.onKeyboardDidHide + */ + +var IS_ANIMATING_TOUCH_START_THRESHOLD_MS = 16; + +var ScrollResponderMixin = { + mixins: [Subscribable.Mixin], + statics: RKScrollViewConsts, + scrollResponderMixinGetInitialState: function() { + return { + isTouching: false, + lastMomentumScrollBeginTime: 0, + lastMomentumScrollEndTime: 0, + + // Reset to false every time becomes responder. This is used to: + // - Determine if the scroll view has been scrolled and therefore should + // refuse to give up its responder lock. + // - Determine if releasing should dismiss the keyboard when we are in + // tap-to-dismiss mode (!this.props.keyboardShouldPersistTaps). + observedScrollSinceBecomingResponder: false, + becameResponderWhileAnimating: false, + }; + }, + + /** + * Invoke this from an `onScroll` event. + */ + scrollResponderHandleScrollShouldSetResponder: function() { + return this.state.isTouching; + }, + + /** + * Merely touch starting is not sufficient for a scroll view to become the + * responder. Being the "responder" means that the very next touch move/end + * event will result in an action/movement. + * + * Invoke this from an `onStartShouldSetResponder` event. + * + * `onStartShouldSetResponder` is used when the next move/end will trigger + * some UI movement/action, but when you want to yield priority to views + * nested inside of the view. + * + * There may be some cases where scroll views actually should return `true` + * from `onStartShouldSetResponder`: Any time we are detecting a standard tap + * that gives priority to nested views. + * + * - If a single tap on the scroll view triggers an action such as + * recentering a map style view yet wants to give priority to interaction + * views inside (such as dropped pins or labels), then we would return true + * from this method when there is a single touch. + * + * - Similar to the previous case, if a two finger "tap" should trigger a + * zoom, we would check the `touches` count, and if `>= 2`, we would return + * true. + * + */ + scrollResponderHandleStartShouldSetResponder: function() { + return false; + }, + + /** + * There are times when the scroll view wants to become the responder + * (meaning respond to the next immediate `touchStart/touchEnd`), in a way + * that *doesn't* give priority to nested views (hence the capture phase): + * + * - Currently animating. + * - Tapping anywhere that is not the focused input, while the keyboard is + * up (which should dismiss the keyboard). + * + * Invoke this from an `onStartShouldSetResponderCapture` event. + */ + scrollResponderHandleStartShouldSetResponderCapture: function(e) { + // First see if we want to eat taps while the keyboard is up + var currentlyFocusedTextInput = TextInputState.currentlyFocusedField(); + if (!this.props.keyboardShouldPersistTaps && + currentlyFocusedTextInput != null && + e.target != currentlyFocusedTextInput) { + return true; + } + return this.scrollResponderIsAnimating(); + }, + + /** + * Invoke this from an `onResponderReject` event. + * + * Some other element is not yielding its role as responder. Normally, we'd + * just disable the `UIScrollView`, but a touch has already began on it, the + * `UIScrollView` will not accept being disabled after that. The easiest + * solution for now is to accept the limitation of disallowing this + * altogether. To improve this, find a way to disable the `UIScrollView` after + * a touch has already started. + */ + scrollResponderHandleResponderReject: function() { + warning(false, "ScrollView doesn't take rejection well - scrolls anyway"); + }, + + /** + * We will allow the scroll view to give up its lock iff it acquired the lock + * during an animation. This is a very useful default that happens to satisfy + * many common user experiences. + * + * - Stop a scroll on the left edge, then turn that into an outer view's + * backswipe. + * - Stop a scroll mid-bounce at the top, continue pulling to have the outer + * view dismiss. + * - However, without catching the scroll view mid-bounce (while it is + * motionless), if you drag far enough for the scroll view to become + * responder (and therefore drag the scroll view a bit), any backswipe + * navigation of a swipe gesture higher in the view hierarchy, should be + * rejected. + */ + scrollResponderHandleTerminationRequest: function() { + return !this.state.observedScrollSinceBecomingResponder; + }, + + /** + * Invoke this from an `onTouchEnd` event. + * + * @param {SyntheticEvent} e Event. + */ + scrollResponderHandleTouchEnd: function(e) { + var nativeEvent = e.nativeEvent; + this.state.isTouching = nativeEvent.touches.length !== 0; + this.props.onTouchEnd && this.props.onTouchEnd(e); + }, + + /** + * Invoke this from an `onResponderRelease` event. + */ + scrollResponderHandleResponderRelease: function(e) { + this.props.onResponderRelease && this.props.onResponderRelease(e); + + // By default scroll views will unfocus a textField + // if another touch occurs outside of it + var currentlyFocusedTextInput = TextInputState.currentlyFocusedField(); + if (!this.props.keyboardShouldPersistTaps && + currentlyFocusedTextInput != null && + e.target != currentlyFocusedTextInput && + !this.state.observedScrollSinceBecomingResponder && + !this.state.becameResponderWhileAnimating) { + this.props.onScrollResponderKeyboardDismissed && + this.props.onScrollResponderKeyboardDismissed(e); + TextInputState.blurTextInput(currentlyFocusedTextInput); + } + }, + + scrollResponderHandleScroll: function(e) { + this.state.observedScrollSinceBecomingResponder = true; + this.props.onScroll && this.props.onScroll(e); + }, + + /** + * Invoke this from an `onResponderGrant` event. + */ + scrollResponderHandleResponderGrant: function(e) { + this.state.observedScrollSinceBecomingResponder = false; + this.props.onResponderGrant && this.props.onResponderGrant(e); + this.state.becameResponderWhileAnimating = this.scrollResponderIsAnimating(); + }, + + /** + * Unfortunately, `onScrollBeginDrag` also fires when *stopping* the scroll + * animation, and there's not an easy way to distinguish a drag vs. stopping + * momentum. + * + * Invoke this from an `onScrollBeginDrag` event. + */ + scrollResponderHandleScrollBeginDrag: function(e) { + this.props.onScrollBeginDrag && this.props.onScrollBeginDrag(e); + }, + + /** + * Invoke this from an `onScrollEndDrag` event. + */ + scrollResponderHandleScrollEndDrag: function(e) { + this.props.onScrollEndDrag && this.props.onScrollEndDrag(e); + }, + + /** + * Invoke this from an `onMomentumScrollBegin` event. + */ + scrollResponderHandleMomentumScrollBegin: function(e) { + this.state.lastMomentumScrollBeginTime = Date.now(); + this.props.onMomentumScrollBegin && this.props.onMomentumScrollBegin(e); + }, + + /** + * Invoke this from an `onMomentumScrollEnd` event. + */ + scrollResponderHandleMomentumScrollEnd: function(e) { + this.state.lastMomentumScrollEndTime = Date.now(); + this.props.onMomentumScrollEnd && this.props.onMomentumScrollEnd(e); + }, + + /** + * Invoke this from an `onTouchStart` event. + * + * Since we know that the `SimpleEventPlugin` occurs later in the plugin + * order, after `ResponderEventPlugin`, we can detect that we were *not* + * permitted to be the responder (presumably because a contained view became + * responder). The `onResponderReject` won't fire in that case - it only + * fires when a *current* responder rejects our request. + * + * @param {SyntheticEvent} e Touch Start event. + */ + scrollResponderHandleTouchStart: function(e) { + this.state.isTouching = true; + this.props.onTouchStart && this.props.onTouchStart(e); + }, + + /** + * Invoke this from an `onTouchMove` event. + * + * Since we know that the `SimpleEventPlugin` occurs later in the plugin + * order, after `ResponderEventPlugin`, we can detect that we were *not* + * permitted to be the responder (presumably because a contained view became + * responder). The `onResponderReject` won't fire in that case - it only + * fires when a *current* responder rejects our request. + * + * @param {SyntheticEvent} e Touch Start event. + */ + scrollResponderHandleTouchMove: function(e) { + this.props.onTouchMove && this.props.onTouchMove(e); + }, + + /** + * A helper function for this class that lets us quickly determine if the + * view is currently animating. This is particularly useful to know when + * a touch has just started or ended. + */ + scrollResponderIsAnimating: function() { + var now = Date.now(); + var timeSinceLastMomentumScrollEnd = now - this.state.lastMomentumScrollEndTime; + var isAnimating = timeSinceLastMomentumScrollEnd < IS_ANIMATING_TOUCH_START_THRESHOLD_MS || + this.state.lastMomentumScrollEndTime < this.state.lastMomentumScrollBeginTime; + return isAnimating; + }, + + /** + * A helper function to scroll to a specific point in the scrollview. + * This is currently used to help focus on child textview's, but this + * can also be used to quickly scroll to any element we want to focus + */ + scrollResponderScrollTo: function(offsetX, offsetY) { + RKUIManagerDeprecated.scrollTo(this.getNodeHandle(), offsetX, offsetY); + }, + + /** + * A helper function to zoom to a specific rect in the scrollview. + * @param {object} rect Should have shape {x, y, w, h} + */ + scrollResponderZoomTo: function(rect) { + RKUIManagerDeprecated.zoomToRect(this.getNodeHandle(), rect); + }, + + /** + * This method should be used as the callback to onFocus in a TextInputs' + * parent view. Note that any module using this mixin needs to return + * the parent view's ref in getScrollViewRef() in order to use this method + */ + scrollResponderScrollNativeHandleToKeyboard: function(nodeHandle, additionalOffset) { + this.additionalScrollOffset = additionalOffset || 0; + RKUIManager.measureLayout( + nodeHandle, + this.getNodeHandle(), + this.scrollResponderTextInputFocusError, + this.scrollResponderInputMeasureAndScrollToKeyboard + ); + }, + + /** + * The calculations performed here assume the scroll view takes up the entire + * screen - even if has some content inset. We then measure the offsets of the + * keyboard, and compensate both for the scroll view's "contentInset". + * + * @param {number} left Position of input w.r.t. table view. + * @param {number} top Position of input w.r.t. table view. + * @param {number} width Width of the text input. + * @param {number} height Height of the text input. + */ + scrollResponderInputMeasureAndScrollToKeyboard: function(left, top, width, height) { + if (this.keyboardWillOpenTo) { + var scrollOffsetY = + top - this.keyboardWillOpenTo.endCoordinates.screenY + height + + this.additionalScrollOffset; + this.scrollResponderScrollTo(0, scrollOffsetY); + } + this.additionalOffset = 0; + }, + + scrollResponderTextInputFocusError: function(e) { + console.error('Error measuring text field: ', e); + }, + + /** + * `componentWillMount` is the closest thing to a standard "constructor" for + * React components. + * + * The `keyboardWillShow` is called before input focus. + */ + componentWillMount: function() { + this.keyboardWillOpenTo = null; + this.additionalScrollOffset = 0; + this.addListenerOn(RCTDeviceEventEmitter, 'keyboardWillShow', this.scrollResponderKeyboardWillShow); + this.addListenerOn(RCTDeviceEventEmitter, 'keyboardWillHide', this.scrollResponderKeyboardWillHide); + this.addListenerOn(RCTDeviceEventEmitter, 'keyboardDidShow', this.scrollResponderKeyboardDidShow); + this.addListenerOn(RCTDeviceEventEmitter, 'keyboardDidHide', this.scrollResponderKeyboardDidHide); + }, + + /** + * Warning, this may be called several times for a single keyboard opening. + * It's best to store the information in this method and then take any action + * at a later point (either in `keyboardDidShow` or other). + * + * Here's the order that events occur in: + * - focus + * - willShow {startCoordinates, endCoordinates} several times + * - didShow several times + * - blur + * - willHide {startCoordinates, endCoordinates} several times + * - didHide several times + * + * The `ScrollResponder` providesModule callbacks for each of these events. + * Even though any user could have easily listened to keyboard events + * themselves, using these `props` callbacks ensures that ordering of events + * is consistent - and not dependent on the order that the keyboard events are + * subscribed to. This matters when telling the scroll view to scroll to where + * the keyboard is headed - the scroll responder better have been notified of + * the keyboard destination before being instructed to scroll to where the + * keyboard will be. Stick to the `ScrollResponder` callbacks, and everything + * will work. + * + * WARNING: These callbacks will fire even if a keyboard is displayed in a + * different navigation pane. Filter out the events to determine if they are + * relevant to you. (For example, only if you receive these callbacks after + * you had explicitly focused a node etc). + */ + scrollResponderKeyboardWillShow: function(e) { + this.keyboardWillOpenTo = e; + this.props.onKeyboardWillShow && this.props.onKeyboardWillShow(e); + }, + + scrollResponderKeyboardWillHide: function(e) { + this.keyboardWillOpenTo = null; + this.props.onKeyboardWillHide && this.props.onKeyboardWillHide(e); + }, + + scrollResponderKeyboardDidShow: function() { + this.keyboardWillOpenTo = null; + this.props.onKeyboardDidShow && this.props.onKeyboardDidShow(); + }, + + scrollResponderKeyboardDidHide: function() { + this.keyboardWillOpenTo = null; + this.props.onKeyboardDidHide && this.props.onKeyboardDidHide(); + } + +}; + +var ScrollResponder = { + Mixin: ScrollResponderMixin, +}; + +module.exports = ScrollResponder; diff --git a/Libraries/Components/ScrollView/ScrollView.ios.js b/Libraries/Components/ScrollView/ScrollView.ios.js new file mode 100644 index 0000000000..3e425162a7 --- /dev/null +++ b/Libraries/Components/ScrollView/ScrollView.ios.js @@ -0,0 +1,278 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ScrollView + */ +'use strict'; + +var ArrayOfPropType = require('ArrayOfPropType'); +var React = require('React'); +var ReactIOSViewAttributes = require('ReactIOSViewAttributes'); +var ReactIOSTagHandles = require('ReactIOSTagHandles'); +var RKScrollView = require('NativeModules').RKUIManager.RCTScrollView; +var RKScrollViewConsts = RKScrollView.Constants; +var RKUIManager = require('NativeModulesDeprecated').RKUIManager; +var ScrollResponder = require('ScrollResponder'); +var ScrollViewPropTypes = require('ScrollViewPropTypes'); +var StyleSheetPropType = require('StyleSheetPropType'); +var StyleSheet = require('StyleSheet'); +var View = require('View'); +var ViewStylePropTypes = require('ViewStylePropTypes'); + +var createReactIOSNativeComponentClass = require('createReactIOSNativeComponentClass'); +var flattenStyle = require('flattenStyle'); +var invariant = require('invariant'); +var merge = require('merge'); +var nativePropType = require('nativePropType'); +var validAttributesFromPropTypes = require('validAttributesFromPropTypes'); + +var PropTypes = React.PropTypes; + +var SCROLLVIEW = 'ScrollView'; +var INNERVIEW = 'InnerScrollView'; + +/** + * `React` component that wraps platform `RKScrollView` while providing + * integration with touch locking "responder" system. + * + * Doesn't yet support other contained responders from blocking this scroll + * view from becoming the responder. + */ + + var RKScrollViewPropTypes = merge( + ScrollViewPropTypes, + { + /** + * When true, the scroll view bounces horizontally when it reaches the end + * even if the content is smaller than the scroll view itself. The default + * value is true when `horizontal={true}` and false otherwise. + */ + alwaysBounceHorizontal: nativePropType(PropTypes.bool), + /** + * When true, the scroll view bounces vertically when it reaches the end + * even if the content is smaller than the scroll view itself. The default + * value is false when `horizontal={true}` and true otherwise. + */ + alwaysBounceVertical: nativePropType(PropTypes.bool), + /** + * When true, the scroll view automatically centers the content when the + * content is smaller than the scroll view bounds; when the content is + * larger than the scroll view, this property has no effect. The default + * value is false. + */ + centerContent: nativePropType(PropTypes.bool), + /** + * These styles will be applied to the scroll view content container which + * wraps all of the child views. Example: + * + * return ( + * + * + * ); + * ... + * var styles = StyleSheet.create({ + * contentContainer: { + * paddingVertical: 20 + * } + * }); + */ + contentContainerStyle: StyleSheetPropType(ViewStylePropTypes), + /** + * A floating-point number that determines how quickly the scroll view + * decelerates after the user lifts their finger. Reasonable choices include + * `RKScrollView.Constants.DecelerationRate.Normal` (the default) and + * `RKScrollView.Constants.DecelerationRate.Fast`. + */ + decelerationRate: nativePropType(PropTypes.number), + /** + * When true, the scroll view's children are arranged horizontally in a row + * instead of vertically in a column. The default value is false. + */ + horizontal: PropTypes.bool, + /** + * Determines whether the keyboard gets dismissed in response to a drag. + * When `ScrollView.keyboardDismissMode.None` (the default), drags do not + * dismiss the keyboard. When `ScrollView.keyboardDismissMode.OnDrag`, the + * keyboard is dismissed when a drag begins. When + * `ScrollView.keyboardDismissMode.Interactive`, the keyboard is dismissed + * interactively with the drag and moves in synchrony with the touch; + * dragging upwards cancels the dismissal. + */ + keyboardDismissMode: nativePropType(PropTypes.oneOf([ + RKScrollViewConsts.KeyboardDismissMode.None, // default + RKScrollViewConsts.KeyboardDismissMode.Interactive, + RKScrollViewConsts.KeyboardDismissMode.OnDrag, + ])), + /** + * When false, tapping outside of the focused text input when the keyboard + * is up dismisses the keyboard. When true, the scroll view will not catch + * taps, and the keyboard will not dismiss automatically. The default value + * is false. + */ + keyboardShouldPersistTaps: nativePropType(PropTypes.bool), + /** + * The maximum allowed zoom scale. The default value is 1.0. + */ + maximumZoomScale: nativePropType(PropTypes.number), + /** + * The minimum allowed zoom scale. The default value is 1.0. + */ + minimumZoomScale: nativePropType(PropTypes.number), + /** + * When true, the scroll view stops on multiples of the scroll view's size + * when scrolling. This can be used for horizontal pagination. The default + * value is false. + */ + pagingEnabled: nativePropType(PropTypes.bool), + /** + * When true, the scroll view scrolls to top when the status bar is tapped. + * The default value is true. + */ + scrollsToTop: nativePropType(PropTypes.bool), + /** + * An array of child indices determining which children get docked to the + * top of the screen when scrolling. For example, passing + * `stickyHeaderIndices={[0]}` will cause the first child to be fixed to the + * top of the scroll view. This property is not supported in conjunction + * with `horizontal={true}`. + */ + stickyHeaderIndices: nativePropType(ArrayOfPropType(PropTypes.number)), + /** + * Experimental: When true, offscreen child views (whose `overflow` value is + * `hidden`) are removed from their native backing superview when offscreen. + * This canimprove scrolling performance on long lists. The default value is + * false. + */ + removeClippedSubviews: PropTypes.bool, + /** + * The current scale of the scroll view content. The default value is 1.0. + */ + zoomScale: nativePropType(PropTypes.number), + } +); + +var ScrollView = React.createClass({ + statics: { + PropTypes: RKScrollViewPropTypes, + keyboardDismissMode: RKScrollViewConsts.KeyboardDismissMode, + }, + + propTypes: RKScrollViewPropTypes, + + mixins: [ScrollResponder.Mixin], + + getInitialState: function() { + return this.scrollResponderMixinGetInitialState(); + }, + + setNativeProps: function(props) { + this.refs[SCROLLVIEW].setNativeProps(props); + }, + + getInnerViewNode: function() { + return this.refs[INNERVIEW].getNodeHandle(); + }, + + scrollTo: function(destY, destX) { + RKUIManager.scrollTo(ReactIOSTagHandles.rootNodeIDToTag[this._rootNodeID], destX, destY); + }, + + render: function() { + var contentContainerStyle = [ + this.props.horizontal && styles.contentContainerHorizontal, + this.props.contentContainerStyle, + ]; + if (__DEV__ && this.props.style) { + var style = flattenStyle(this.props.style); + var childLayoutProps = ['alignItems', 'justifyContent'] + .filter((prop) => style[prop] !== undefined); + invariant( + childLayoutProps.length === 0, + 'ScrollView child layout (' + JSON.stringify(childLayoutProps) + + ') must by applied through the contentContainerStyle prop.' + ); + } + if (__DEV__) { + if (this.props.onScroll && !this.props.throttleScrollCallbackMS) { + var onScroll = this.props.onScroll; + this.props.onScroll = function() { + console.log( + 'You specified `onScroll` on a but not ' + + '`throttleScrollCallbackMS`. You will only receive one event. ' + + 'Using `16` you get all the events but be aware that it may ' + + 'cause frame drops, use a bigger number if you don\'t need as ' + + 'much precision.' + ); + onScroll.apply(this, arguments); + }; + } + } + + var contentContainer = + + {this.props.children} + ; + + var alwaysBounceHorizontal = + this.props.alwaysBounceHorizontal !== undefined ? + this.props.alwaysBounceHorizontal : + this.props.horizontal; + + var alwaysBounceVertical = + this.props.alwaysBounceVertical !== undefined ? + this.props.alwaysBounceVertical : + !this.props.horizontal; + + var props = merge( + this.props, { + alwaysBounceHorizontal, + alwaysBounceVertical, + style: [styles.base, this.props.style], + onTouchStart: this.scrollResponderHandleTouchStart, + onTouchMove: this.scrollResponderHandleTouchMove, + onTouchEnd: this.scrollResponderHandleTouchEnd, + onScrollBeginDrag: this.scrollResponderHandleScrollBeginDrag, + onScrollEndDrag: this.scrollResponderHandleScrollEndDrag, + onMomentumScrollBegin: this.scrollResponderHandleMomentumScrollBegin, + onMomentumScrollEnd: this.scrollResponderHandleMomentumScrollEnd, + onStartShouldSetResponder: this.scrollResponderHandleStartShouldSetResponder, + onStartShouldSetResponderCapture: this.scrollResponderHandleStartShouldSetResponderCapture, + onScrollShouldSetResponder: this.scrollResponderHandleScrollShouldSetResponder, + onScroll: this.scrollResponderHandleScroll, + onResponderGrant: this.scrollResponderHandleResponderGrant, + onResponderTerminationRequest: this.scrollResponderHandleTerminationRequest, + onResponderTerminate: this.scrollResponderHandleTerminate, + onResponderRelease: this.scrollResponderHandleResponderRelease, + onResponderReject: this.scrollResponderHandleResponderReject, + } + ); + return ( + + {contentContainer} + + ); + } +}); + +var styles = StyleSheet.create({ + base: { + flex: 1, + }, + contentContainerHorizontal: { + alignSelf: 'flex-start', + flexDirection: 'row', + }, +}); + +var RKScrollView = createReactIOSNativeComponentClass({ + validAttributes: merge( + ReactIOSViewAttributes.UIView, + validAttributesFromPropTypes(ScrollView.propTypes) + ), + uiViewClassName: 'RCTScrollView', +}); + +module.exports = ScrollView; diff --git a/Libraries/Components/ScrollViewPropTypes.js b/Libraries/Components/ScrollViewPropTypes.js new file mode 100644 index 0000000000..12af365283 --- /dev/null +++ b/Libraries/Components/ScrollViewPropTypes.js @@ -0,0 +1,30 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ScrollViewPropTypes + */ +'use strict'; + +var EdgeInsetsPropType = require('EdgeInsetsPropType'); +var PointPropType = require('PointPropType'); +var PropTypes = require('ReactPropTypes'); +var StyleSheetPropType = require('StyleSheetPropType'); +var ViewStylePropTypes = require('ViewStylePropTypes'); + +var nativePropType = require('nativePropType'); + +var ScrollViewPropTypes = { + automaticallyAdjustContentInsets: nativePropType(PropTypes.bool), // true + contentInset: nativePropType(EdgeInsetsPropType), // zeroes + contentOffset: nativePropType(PointPropType), // zeroes + onScroll: PropTypes.func, + onScrollAnimationEnd: PropTypes.func, + scrollEnabled: nativePropType(PropTypes.bool), // true + scrollIndicatorInsets: nativePropType(EdgeInsetsPropType), // zeros + showsHorizontalScrollIndicator: nativePropType(PropTypes.bool), + showsVerticalScrollIndicator: nativePropType(PropTypes.bool), + style: StyleSheetPropType(ViewStylePropTypes), + throttleScrollCallbackMS: nativePropType(PropTypes.number), // 200ms +}; + +module.exports = ScrollViewPropTypes; diff --git a/Libraries/Components/StaticRenderer.js b/Libraries/Components/StaticRenderer.js new file mode 100644 index 0000000000..b749895c2b --- /dev/null +++ b/Libraries/Components/StaticRenderer.js @@ -0,0 +1,25 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @provides StaticRenderer + */ +'use strict'; + +var React = require('React'); + +var StaticRenderer = React.createClass({ + propTypes: { + shouldUpdate: React.PropTypes.bool.isRequired, + render: React.PropTypes.func.isRequired, + }, + + shouldComponentUpdate: function(nextProps) { + return nextProps.shouldUpdate; + }, + + render: function() { + return this.props.render(); + }, +}); + +module.exports = StaticRenderer; diff --git a/Libraries/Components/StatusBar/StatusBarIOS.ios.js b/Libraries/Components/StatusBar/StatusBarIOS.ios.js new file mode 100644 index 0000000000..eaea1cc7ac --- /dev/null +++ b/Libraries/Components/StatusBar/StatusBarIOS.ios.js @@ -0,0 +1,35 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule StatusBarIOS + * @flow + */ +'use strict'; + +var { RKStatusBarManager } = require('NativeModules'); + +var StatusBarIOS = { + + Style: { + default: RKStatusBarManager.Style.default, + lightContent: RKStatusBarManager.Style.lightContent + }, + + Animation: { + none: RKStatusBarManager.Animation.none, + fade: RKStatusBarManager.Animation.fade, + slide: RKStatusBarManager.Animation.slide, + }, + + setStyle(style: number, animated: boolean) { + animated = animated || false; + RKStatusBarManager.setStyle(style, animated); + }, + + setHidden(hidden: boolean, animation: number) { + animation = animation || StatusBarIOS.Animation.none; + RKStatusBarManager.setHidden(hidden, animation); + }, +}; + +module.exports = StatusBarIOS; diff --git a/Libraries/Components/Subscribable.js b/Libraries/Components/Subscribable.js new file mode 100644 index 0000000000..8c4bbe8235 --- /dev/null +++ b/Libraries/Components/Subscribable.js @@ -0,0 +1,333 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule Subscribable + */ +'use strict'; + +/** + * Subscribable wraps EventEmitter in a clean interface, and provides a mixin + * so components can easily subscribe to events and not worry about cleanup on + * unmount. + * + * Also acts as a basic store because it records the last data that it emitted, + * and provides a way to populate the initial data. The most recent data can be + * fetched from the Subscribable by calling `get()` + * + * Advantages over EventEmitter + Subscibable.Mixin.addListenerOn: + * - Cleaner usage: no strings to identify the event + * - Lifespan pattern enforces cleanup + * - More logical: Subscribable.Mixin now uses a Subscribable class + * - Subscribable saves the last data and makes it available with `.get()` + * + * Legacy Subscribable.Mixin.addListenerOn allowed automatic subscription to + * EventEmitters. Now we should avoid EventEmitters and wrap with Subscribable + * instead: + * + * ``` + * AppState.networkReachability = new Subscribable( + * RCTDeviceEventEmitter, + * 'reachabilityDidChange', + * (resp) => resp.network_reachability, + * RKReachability.getCurrentReachability + * ); + * + * var myComponent = React.createClass({ + * mixins: [Subscribable.Mixin], + * getInitialState: function() { + * return { + * isConnected: AppState.networkReachability.get() !== 'none' + * }; + * }, + * componentDidMount: function() { + * this._reachSubscription = this.subscribeTo( + * AppState.networkReachability, + * (reachability) => { + * this.setState({ isConnected: reachability !== 'none' }) + * } + * ); + * }, + * render: function() { + * return ( + * + * {this.state.isConnected ? 'Network Connected' : 'No network'} + * + * this._reachSubscription.remove()}> + * End reachability subscription + * + * ); + * } + * }); + * ``` + */ + +var EventEmitter = require('EventEmitter'); + +var invariant = require('invariant'); +var logError = require('logError'); + +var SUBSCRIBABLE_INTERNAL_EVENT = 'subscriptionEvent'; + + +class Subscribable { + /** + * Creates a new Subscribable object + * + * @param {EventEmitter} eventEmitter Emitter to trigger subscription events. + * @param {string} eventName Name of emitted event that triggers subscription + * events. + * @param {function} eventMapping (optional) Function to convert the output + * of the eventEmitter to the subscription output. + * @param {function} getInitData (optional) Async function to grab the initial + * data to publish. Signature `function(successCallback, errorCallback)`. + * The resolved data will be transformed with the eventMapping before it + * gets emitted. + */ + constructor(eventEmitter, eventName, eventMapping, getInitData) { + + this._internalEmitter = new EventEmitter(); + this._eventMapping = eventMapping || (data => data); + + this._upstreamSubscription = eventEmitter.addListener( + eventName, + this._handleEmit, + this + ); + + // Asyncronously get the initial data, if provided + getInitData && getInitData(this._handleInitData.bind(this), logError); + } + + /** + * Returns the last data emitted from the Subscribable, or undefined + */ + get() { + return this._lastData; + } + + /** + * Unsubscribe from the upstream EventEmitter + */ + cleanup() { + this._upstreamSubscription && this._upstreamSubscription.remove(); + } + + /** + * Add a new listener to the subscribable. This should almost never be used + * directly, and instead through Subscribable.Mixin.subscribeTo + * + * @param {object} lifespan Object with `addUnmountCallback` that accepts + * a handler to be called when the component unmounts. This is required and + * desirable because it enforces cleanup. There is no easy way to leave the + * subsciption hanging + * { + * addUnmountCallback: function(newUnmountHanlder) {...}, + * } + * @param {function} callback Handler to call when Subscribable has data + * updates + * @param {object} context Object to bind the handler on, as "this" + * + * @return {object} the subscription object: + * { + * remove: function() {...}, + * } + * Call `remove` to terminate the subscription before unmounting + */ + subscribe(lifespan, callback, context) { + invariant( + typeof lifespan.addUnmountCallback === 'function', + 'Must provide a valid lifespan, which provides a way to add a ' + + 'callback for when subscription can be cleaned up. This is used ' + + 'automatically by Subscribable.Mixin' + ); + invariant( + typeof callback === 'function', + 'Must provide a valid subscription handler.' + ); + + // Add a listener to the internal EventEmitter + var subscription = this._internalEmitter.addListener( + SUBSCRIBABLE_INTERNAL_EVENT, + callback, + context + ); + + // Clean up subscription upon the lifespan unmount callback + lifespan.addUnmountCallback(() => { + subscription.remove(); + }); + + return subscription; + } + + /** + * Callback for the initial data resolution. Currently behaves the same as + * `_handleEmit`, but we may eventually want to keep track of the difference + */ + _handleInitData(dataInput) { + var emitData = this._eventMapping(dataInput); + this._lastData = emitData; + this._internalEmitter.emit(SUBSCRIBABLE_INTERNAL_EVENT, emitData); + } + + /** + * Handle new data emissions. Pass the data through our eventMapping + * transformation, store it for later `get()`ing, and emit it for subscribers + */ + _handleEmit(dataInput) { + var emitData = this._eventMapping(dataInput); + this._lastData = emitData; + this._internalEmitter.emit(SUBSCRIBABLE_INTERNAL_EVENT, emitData); + } +} + + +Subscribable.Mixin = { + + /** + * @return {object} lifespan Object with `addUnmountCallback` that accepts + * a handler to be called when the component unmounts + * { + * addUnmountCallback: function(newUnmountHanlder) {...}, + * } + */ + _getSubscribableLifespan: function() { + if (!this._subscribableLifespan) { + this._subscribableLifespan = { + addUnmountCallback: (cb) => { + this._endSubscribableLifespanCallbacks.push(cb); + }, + }; + } + return this._subscribableLifespan; + }, + + _endSubscribableLifespan: function() { + this._endSubscribableLifespanCallbacks.forEach(cb => cb()); + }, + + /** + * Components use `subscribeTo` for listening to Subscribable stores. Cleanup + * is automatic on component unmount. + * + * To stop listening to the subscribable and end the subscription early, + * components should store the returned subscription object and invoke the + * `remove()` function on it + * + * @param {Subscribable} subscription to subscribe to. + * @param {function} listener Function to invoke when event occurs. + * @param {object} context Object to bind the handler on, as "this" + * + * @return {object} the subscription object: + * { + * remove: function() {...}, + * } + * Call `remove` to terminate the subscription before unmounting + */ + subscribeTo: function(subscribable, handler, context) { + invariant( + subscribable instanceof Subscribable, + 'Must provide a Subscribable' + ); + return subscribable.subscribe( + this._getSubscribableLifespan(), + handler, + context + ); + }, + + /** + * Gets a Subscribable store, scoped to the component, that can be passed to + * children. The component will automatically clean up the subscribable's + * subscription to the eventEmitter when unmounting. + * + * `provideSubscribable` will always return the same Subscribable for any + * particular emitter/eventName combo, so it can be called directly from + * render, and it will never create duplicate Subscribables. + * + * @param {EventEmitter} eventEmitter Emitter to trigger subscription events. + * @param {string} eventName Name of emitted event that triggers subscription + * events. + * @param {function} eventMapping (optional) Function to convert the output + * of the eventEmitter to the subscription output. + * @param {function} getInitData (optional) Async function to grab the initial + * data to publish. Signature `function(successCallback, errorCallback)`. + * The resolved data will be transformed with the eventMapping before it + * gets emitted. + */ + provideSubscribable: function(eventEmitter, eventName, eventMapping, getInitData) { + this._localSubscribables = this._localSubscribables || {}; + this._localSubscribables[eventEmitter] = + this._localSubscribables[eventEmitter] || {}; + if (!this._localSubscribables[eventEmitter][eventName]) { + this._localSubscribables[eventEmitter][eventName] = + new Subscribable(eventEmitter, eventName, eventMapping, getInitData); + } + return this._localSubscribables[eventEmitter][eventName]; + }, + + /** + * Removes any local Subscribables created with `provideSubscribable`, so the + * component can unmount without leaving any dangling listeners on + * eventEmitters + */ + _cleanupLocalSubscribables: function() { + if (!this._localSubscribables) { + return; + } + var emitterSubscribables; + Object.keys(this._localSubscribables).forEach((eventEmitter) => { + emitterSubscribables = this._localSubscribables[eventEmitter]; + Object.keys(emitterSubscribables).forEach((eventName) => { + emitterSubscribables[eventName].cleanup(); + }); + }); + this._localSubscribables = null; + }, + + componentWillMount: function() { + this._endSubscribableLifespanCallbacks = []; + + // DEPRECATED addListenerOn* usage: + this._subscribableSubscriptions = []; + }, + + componentWillUnmount: function() { + // Resolve the lifespan, which will cause Subscribable to clean any + // remaining subscriptions + this._endSubscribableLifespan && this._endSubscribableLifespan(); + + this._cleanupLocalSubscribables(); + + // DEPRECATED addListenerOn* usage uses _subscribableSubscriptions array + // instead of lifespan + this._subscribableSubscriptions.forEach( + (subscription) => subscription.remove() + ); + this._subscribableSubscriptions = null; + }, + + /** + * DEPRECATED - Use `Subscribable` and `Mixin.subscribeTo` instead. + * `addListenerOn` subscribes the component to an `EventEmitter`. + * + * Special form of calling `addListener` that *guarantees* that a + * subscription *must* be tied to a component instance, and therefore will + * be cleaned up when the component is unmounted. It is impossible to create + * the subscription and pass it in - this method must be the one to create + * the subscription and therefore can guarantee it is retained in a way that + * will be cleaned up. + * + * @param {EventEmitter} eventEmitter emitter to subscribe to. + * @param {string} eventType Type of event to listen to. + * @param {function} listener Function to invoke when event occurs. + * @param {object} context Object to use as listener context. + */ + addListenerOn: function(eventEmitter, eventType, listener, context) { + this._subscribableSubscriptions.push( + eventEmitter.addListener(eventType, listener, context) + ); + } +}; + +module.exports = Subscribable; diff --git a/Libraries/Components/Text/ExpandingText.js b/Libraries/Components/Text/ExpandingText.js new file mode 100644 index 0000000000..80b06492a1 --- /dev/null +++ b/Libraries/Components/Text/ExpandingText.js @@ -0,0 +1,120 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ExpandingText + */ +'use strict'; + +var React = require('React'); +var StyleSheet = require('StyleSheet'); +var Text = require('Text'); +var TouchableWithoutFeedback = require('TouchableWithoutFeedback'); +var View = require('View'); + +var truncate = require('truncate'); + +var styles = StyleSheet.create({ + boldText: { + fontWeight: 'bold', + }, +}); + +/** + * - A react component for displaying text which supports truncating + * based on a set truncLength. In the following example, the text will truncate + * to show only the first 17 characters plus '...' with a See More button to + * expand the text to its full length + * + * renderText: function() { + * return ; + * }, + * + * More example code in `ExpandingTextExample.js` + */ +var ExpandingText = React.createClass({ + PropTypes: { + /** + * Text to be displayed. Text will be truncated if the character length + * is greater than the truncLength property. + */ + text: React.PropTypes.string.isRequired, + /** + * The styles that will be applied to the text (both truncated and expanded). + */ + textStyle: Text.stylePropType, + /** + * The styles that will be applied to the See More button + */ + seeMoreStyle: Text.stylePropType, + /** + * The maximum character length for the text that will + * be displayed by default. Note that ... will be + * appended to the truncated text which is counted towards + * the total truncLength of the default displayed string + */ + truncLength: React.PropTypes.number + }, + + getDefaultProps: function() { + return { + truncLength: 130, + seeMoreText: 'See More', + seeMoreStyle: styles.boldText, + }; + }, + + getInitialState: function() { + return { + truncated: true, + }; + }, + + onTapSeeMore: function() { + this.setState({ + truncated: !this.state.truncated, + }); + }, + + isTruncated: function() { + return ( + this.props.text.length > this.props.truncLength && + this.state.truncated + ); + }, + + getText: function() { + var text = this.props.text; + if (!this.isTruncated()) { + return text; + } + + return truncate(text, this.props.truncLength) + ' '; + }, + + renderSeeMore: function() { + if (!this.isTruncated()) { + return null; + } + + return ( + + {this.props.seeMoreText} + + ); + }, + + render: function() { + return ( + + + + {this.getText()} + {this.renderSeeMore()} + + + + ); + } +}); + +module.exports = ExpandingText; diff --git a/Libraries/Components/Text/Text.js b/Libraries/Components/Text/Text.js new file mode 100644 index 0000000000..07160bc669 --- /dev/null +++ b/Libraries/Components/Text/Text.js @@ -0,0 +1,195 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule Text + * @typechecks static-only + */ +'use strict'; + +var NativeMethodsMixin = require('NativeMethodsMixin'); +var React = require('React'); +var ReactIOSViewAttributes = require('ReactIOSViewAttributes'); +var StyleSheetPropType = require('StyleSheetPropType'); +var TextStylePropTypes = require('TextStylePropTypes'); +var Touchable = require('Touchable'); + +var createReactIOSNativeComponentClass = + require('createReactIOSNativeComponentClass'); +var merge = require('merge'); + +var stylePropType = StyleSheetPropType(TextStylePropTypes); + +var viewConfig = { + validAttributes: merge(ReactIOSViewAttributes.UIView, { + isHighlighted: true, + numberOfLines: true, + }), + uiViewClassName: 'RCTText', +}; + +/** + * - A react component for displaying text which supports nesting, + * styling, and touch handling. In the following example, the nested title and + * body text will inherit the `fontFamily` from `styles.baseText`, but the title + * provides its own additional styles. The title and body will stack on top of + * each other on account of the literal newlines: + * + * renderText: function() { + * return ( + * + * + * {this.state.titleText + '\n\n'} + * + * + * {this.state.bodyText} + * + * + * ); + * }, + * ... + * var styles = StyleSheet.create({ + * baseText: { + * fontFamily: 'Cochin', + * }, + * titleText: { + * fontSize: 20, + * fontWeight: 'bold', + * }, + * }; + * + * More example code in `TextExample.ios.js` + */ + +var Text = React.createClass({ + + mixins: [Touchable.Mixin, NativeMethodsMixin], + + statics: { + stylePropType: stylePropType, + }, + + propTypes: { + /** + * Used to truncate the text with an elipsis after computing the text + * layout, including line wrapping, such that the total number of lines does + * not exceed this number. + */ + numberOfLines: React.PropTypes.number, + /** + * This function is called on press. Text intrinsically supports press + * handling with a default highlight state (which can be disabled with + * `suppressHighlighting`). + */ + onPress: React.PropTypes.func, + /** + * When true, no visual change is made when text is pressed down. By + * default, a gray oval highlights the text on press down. + */ + suppressHighlighting: React.PropTypes.bool, + style: stylePropType, + }, + + viewConfig: viewConfig, + + getInitialState: function() { + return merge(this.touchableGetInitialState(), { + isHighlighted: false, + }); + }, + + onStartShouldSetResponder: function() { + var shouldSetFromProps = this.props.onStartShouldSetResponder && + this.props.onStartShouldSetResponder(); + return shouldSetFromProps || !!this.props.onPress; + }, + + /* + * Returns true to allow responder termination + */ + handleResponderTerminationRequest: function() { + // Allow touchable or props.onResponderTerminationRequest to deny + // the request + var allowTermination = this.touchableHandleResponderTerminationRequest(); + if (allowTermination && this.props.onResponderTerminationRequest) { + allowTermination = this.props.onResponderTerminationRequest(); + } + return allowTermination; + }, + + handleResponderGrant: function(e, dispatchID) { + this.touchableHandleResponderGrant(e, dispatchID); + this.props.onResponderGrant && + this.props.onResponderGrant.apply(this, arguments); + }, + + handleResponderMove: function(e) { + this.touchableHandleResponderMove(e); + this.props.onResponderMove && + this.props.onResponderMove.apply(this, arguments); + }, + + handleResponderRelease: function(e) { + this.touchableHandleResponderRelease(e); + this.props.onResponderRelease && + this.props.onResponderRelease.apply(this, arguments); + }, + + handleResponderTerminate: function(e) { + this.touchableHandleResponderTerminate(e); + this.props.onResponderTerminate && + this.props.onResponderTerminate.apply(this, arguments); + }, + + touchableHandleActivePressIn: function() { + if (this.props.suppressHighlighting || !this.props.onPress) { + return; + } + this.setState({ + isHighlighted: true, + }); + }, + + touchableHandleActivePressOut: function() { + if (this.props.suppressHighlighting || !this.props.onPress) { + return; + } + this.setState({ + isHighlighted: false, + }); + }, + + touchableHandlePress: function() { + this.props.onPress && this.props.onPress(); + }, + + touchableGetPressRectOffset: function() { + return PRESS_RECT_OFFSET; + }, + + render: function() { + var props = {}; + for (var key in this.props) { + props[key] = this.props[key]; + } + props.ref = this.getNodeHandle(); + // Text is accessible by default + if (props.accessible !== false) { + props.accessible = true; + } + props.isHighlighted = this.state.isHighlighted; + props.onStartShouldSetResponder = this.onStartShouldSetResponder; + props.onResponderTerminationRequest = + this.handleResponderTerminationRequest; + props.onResponderGrant = this.handleResponderGrant; + props.onResponderMove = this.handleResponderMove; + props.onResponderRelease = this.handleResponderRelease; + props.onResponderTerminate = this.handleResponderTerminate; + return ; + }, +}); + +var PRESS_RECT_OFFSET = {top: 20, left: 20, right: 20, bottom: 30}; + +var RKText = createReactIOSNativeComponentClass(viewConfig); + +module.exports = Text; diff --git a/Libraries/Components/Text/TextStylePropTypes.js b/Libraries/Components/Text/TextStylePropTypes.js new file mode 100644 index 0000000000..0421794d2f --- /dev/null +++ b/Libraries/Components/Text/TextStylePropTypes.js @@ -0,0 +1,46 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule TextStylePropTypes + */ +'use strict'; + +var ReactPropTypes = require('ReactPropTypes'); +var ViewStylePropTypes = require('ViewStylePropTypes'); + +var merge = require('merge'); + +var TextStylePropTypes = merge( + ViewStylePropTypes, { + fontFamily: ReactPropTypes.string, + fontSize: ReactPropTypes.number, + fontWeight: ReactPropTypes.oneOf(['normal' /*default*/, 'bold']), + fontStyle: ReactPropTypes.oneOf(['normal', 'italic']), + lineHeight: ReactPropTypes.number, + color: ReactPropTypes.string, + containerBackgroundColor: ReactPropTypes.string, + textAlign: ReactPropTypes.oneOf( + ['auto' /*default*/, 'left', 'right', 'center'] + ), + writingDirection: ReactPropTypes.oneOf( + ['auto' /*default*/, 'ltr', 'rtl'] + ), + } +); + +// Text doesn't support padding correctly (#4841912) +var unsupportedProps = Object.keys({ + padding: null, + paddingTop: null, + paddingLeft: null, + paddingRight: null, + paddingBottom: null, + paddingVertical: null, + paddingHorizontal: null, +}); + +for (var key in unsupportedProps) { + delete TextStylePropTypes[key]; +} + +module.exports = TextStylePropTypes; diff --git a/Libraries/Components/TextInput/TextInput.ios.js b/Libraries/Components/TextInput/TextInput.ios.js new file mode 100644 index 0000000000..2bd194d17c --- /dev/null +++ b/Libraries/Components/TextInput/TextInput.ios.js @@ -0,0 +1,431 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule TextInput + */ +'use strict'; + +var DocumentSelectionState = require('DocumentSelectionState'); +var EventEmitter = require('EventEmitter'); +var NativeMethodsMixin = require('NativeMethodsMixin'); +var NativeModulesDeprecated = require('NativeModulesDeprecated'); +var PropTypes = require('ReactPropTypes'); +var React = require('React'); +var ReactChildren = require('ReactChildren'); +var ReactIOSViewAttributes = require('ReactIOSViewAttributes'); +var StyleSheet = require('StyleSheet'); +var Subscribable = require('Subscribable'); +var Text = require('Text'); +var TextInputState = require('TextInputState'); +var TimerMixin = require('TimerMixin'); +var TouchableWithoutFeedback = require('TouchableWithoutFeedback'); + +var createReactIOSNativeComponentClass = require('createReactIOSNativeComponentClass'); +var emptyFunction = require('emptyFunction'); +var getObjectValues = require('getObjectValues'); +var invariant = require('invariant'); +var merge = require('merge'); + +/** + * - A foundational component for inputting text into the app via a + * keyboard. Props provide configurability for several features, such as auto- + * correction, auto-capitalization, placeholder text, and different keyboard + * types, such as a numeric keypad. + * + * The simplest use case is to plop down a `TextInput` and subscribe to the + * `onChangeText` events to read the user input. There are also other events, such + * as `onSubmitEditing` and `onFocus` that can be subscribed to. A simple + * example: + * + * + * this.setState({input: text})} + * /> + * {'user input: ' + this.state.input} + * + * + * The `value` prop can be used to set the value of the input in order to make + * the state of the component clear, but does not behave as a true + * controlled component by default because all operations are asynchronous. + * Setting `value` once is like setting the default value, but you can change it + * continuously based on `onChangeText` events as well. If you really want to + * force the component to always revert to the value you are setting, you can + * set `controlled={true}`. + * + * The `multiline` prop is not supported in all releases, and some props are + * multiline only. + * + * More example code in `TextInputExample.js`. + */ + +var nativeConstants = NativeModulesDeprecated.RKUIManager.UIText.AutocapitalizationType; + +var autoCapitalizeMode = { + none: nativeConstants.None, + sentences: nativeConstants.Sentences, + words: nativeConstants.Words, + characters: nativeConstants.AllCharacters +}; + +var keyboardType = { + default: 'default', + numeric: 'numeric', +}; + +var RKTextViewAttributes = merge(ReactIOSViewAttributes.UIView, { + autoCorrect: true, + autoCapitalize: true, + color: true, + editable: true, + fontSize: true, + fontWeight: true, + keyboardType: true, + mostRecentEventCounter: true, + placeholder: true, + placeholderTextColor: true, + text: true, +}); + +var RKTextFieldAttributes = merge(RKTextViewAttributes, { + caretHidden: true, + enabled: true, +}); + +var onlyMultiline = { + onSelectionChange: true, + onTextInput: true, + children: true, +}; + +var notMultiline = { + onSubmitEditing: true, +}; + +var TextInput = React.createClass({ + statics: { + autoCapitalizeMode: autoCapitalizeMode, + keyboardType: keyboardType, + }, + + propTypes: { + /** + * Can tell TextInput to automatically capitalize certain characters. + * + * - characters: all characters, + * - words: first letter of each word + * - sentences: first letter of each sentence (default) + * - none: don't auto capitalize anything + * + * example: + * autoCapitalize={TextInput.autoCapitalizeMode.words} + */ + autoCapitalize: PropTypes.oneOf(getObjectValues(autoCapitalizeMode)), + /** + * If false, disables auto-correct. Default value is true. + */ + autoCorrect: PropTypes.bool, + /** + * If true, focuses the input on componentDidMount. Default value is false. + */ + autoFocus: PropTypes.bool, + /** + * If false, text is not editable. Default value is true. + */ + editable: PropTypes.bool, + /** + * Determines which keyboard to open, e.g.`TextInput.keyboardType.numeric`. + */ + keyboardType: PropTypes.oneOf(getObjectValues(keyboardType)), + /** + * If true, the text input can be multiple lines. Default value is false. + */ + multiline: PropTypes.bool, + /** + * Callback that is called when the text input is blurred + */ + onBlur: PropTypes.func, + /** + * Callback that is called when the text input is focused + */ + onFocus: PropTypes.func, + /** + * (text: string) => void + * + * Callback that is called when the text input's text changes. + */ + onChangeText: PropTypes.func, + + onEndEditing: PropTypes.func, + onSubmitEditing: PropTypes.func, + /** + * The string that will be rendered before text input has been entered + */ + placeholder: PropTypes.string, + /** + * The text color of the placeholder string + */ + placeholderTextColor: PropTypes.string, + /** + * See DocumentSelectionState.js, some state that is responsible for + * maintaining selection information for a document + */ + selectionState: PropTypes.instanceOf(DocumentSelectionState), + /** + * The default value for the text input + */ + value: PropTypes.string, + /** + * This helps avoid drops characters due to race conditions between JS and + * the native text input. The default should be fine, but if you're + * potentially doing very slow operations on every keystroke then you may + * want to try increasing this. + */ + bufferDelay: PropTypes.number, + /** + * If you really want this to behave as a controlled component, you can set + * this true, but you will probably see flickering, dropped keystrokes, + * and/or laggy typing, depending on how you process onChange events. + */ + controlled: PropTypes.bool, + + style: Text.stylePropType, + }, + + /** + * `NativeMethodsMixin` will look for this when invoking `setNativeProps`. We + * make `this` look like an actual native component class. + */ + mixins: [NativeMethodsMixin, TimerMixin, Subscribable.Mixin], + + viewConfig: { + uiViewClassName: 'RCTTextField', + validAttributes: RKTextFieldAttributes, + }, + + isFocused: function() { + return TextInputState.currentlyFocusedField() === + this.refs.input.getNativeNode(); + }, + + getDefaultProps: function() { + return { + bufferDelay: 100, + }; + }, + + getInitialState: function() { + return { + mostRecentEventCounter: 0, + bufferedValue: this.props.value, + }; + }, + + contextTypes: { + onFocusRequested: React.PropTypes.func, + focusEmitter: React.PropTypes.instanceOf(EventEmitter), + }, + + componentDidMount: function() { + if (!this.context.focusEmitter) { + if (this.props.autoFocus) { + this.requestAnimationFrame(this.focus); + } + return; + } + this.addListenerOn(this.context.focusEmitter, 'focus', (el) => { + if (this === el) { + this.requestAnimationFrame(this.focus); + } else if (this.isFocused()) { + this.blur(); + } + }); + if (this.props.autoFocus) { + this.context.onFocusRequested(this); + } + }, + + componentWillReceiveProps: function(newProps) { + if (newProps.value !== this.props.value) { + if (!this.isFocused()) { + // Set the value immediately if the input is not focused since that + // means there is no risk of the user typing immediately. + this.setState({bufferedValue: newProps.value}); + } else { + // The following clear and setTimeout buffers the value such that if more + // characters are typed in quick succession, generating new values, the + // out of date values will get cancelled before they are ever sent to + // native. + // + // If we don't do this, it's likely the out of date values will blow + // away recently typed characters in the native input that JS was not + // yet aware of (since it is informed asynchronously), then the next + // character will be appended to the older value, dropping the + // characters in between. Here is a potential sequence of events + // (recall we have multiple independently serial, interleaved queues): + // + // 1) User types 'R' => send 'R' to JS queue. + // 2) User types 'e' => send 'Re' to JS queue. + // 3) JS processes 'R' and sends 'R' back to native. + // 4) Native recieves 'R' and changes input from 'Re' back to 'R'. + // 5) User types 'a' => send 'Ra' to JS queue. + // 6) JS processes 'Re' and sends 'Re' back to native. + // 7) Native recieves 'Re' and changes input from 'R' back to 'Re'. + // 8) JS processes 'Ra' and sends 'Ra' back to native. + // 9) Native recieves final 'Ra' from JS - 'e' has been dropped! + // + // This isn't 100% foolproop (e.g. if it takes longer than + // `props.bufferDelay` ms to process one keystroke), and there are of + // course other potential algorithms to deal with this, but this is a + // simple solution that seems to reduce the chance of dropped characters + // drastically without compromising native input responsiveness (e.g. by + // introducing delay from a synchronization protocol). + this.clearTimeout(this._bufferTimeout); + this._bufferTimeout = this.setTimeout( + () => this.setState({bufferedValue: newProps.value}), + this.props.bufferDelay + ); + } + } + }, + + render: function() { + var textContainer; + + if (!this.props.multiline) { + for (var propKey in onlyMultiline) { + if (this.props[propKey]) { + throw new Error( + 'TextInput prop `' + propKey + '` is only supported with multiline.' + ); + } + } + textContainer = + true} + placeholder={this.props.placeholder} + text={this.state.bufferedValue} + autoCapitalize={this.props.autoCapitalize} + autoCorrect={this.props.autoCorrect} + />; + } else { + for (var propKey in notMultiline) { + if (this.props[propKey]) { + throw new Error( + 'TextInput prop `' + propKey + '` cannot be used with multiline.' + ); + } + } + + var children = this.props.children; + var childCount = 0; + ReactChildren.forEach(children, () => ++childCount); + invariant( + !(this.props.value && childCount), + 'Cannot specify both value and children.' + ); + if (childCount > 1) { + children = {children}; + } + if (this.props.inputView) { + children = [children, this.props.inputView]; + } + textContainer = + ; + } + + return ( + + {textContainer} + + ); + }, + + _onFocus: function(event) { + if (this.props.onFocus) { + this.props.onFocus(event); + } + }, + + _onPress: function(event) { + this.focus(); + }, + + _onChange: function(event) { + if (this.props.controlled && event.nativeEvent.text !== this.props.value) { + this.refs.input.setNativeProps({text: this.props.value}); + } + this.props.onChange && this.props.onChange(event); + this.props.onChangeText && this.props.onChangeText(event.nativeEvent.text); + }, + + _onBlur: function(event) { + this.blur(); + if (this.props.onBlur) { + this.props.onBlur(event); + } + }, + + _onSelectionChange: function(event) { + if (this.props.selectionState) { + var selection = event.nativeEvent.selection; + this.props.selectionState.update(selection.start, selection.end); + } + this.props.onSelectionChange && this.props.onSelectionChange(event); + }, + + _onTextInput: function(event) { + this.props.onTextInput && this.props.onTextInput(event); + var counter = event.nativeEvent.eventCounter; + if (counter > this.state.mostRecentEventCounter) { + this.setState({mostRecentEventCounter: counter}); + } + }, +}); + +var styles = StyleSheet.create({ + input: { + alignSelf: 'stretch', + }, +}); + +var RKTextView = createReactIOSNativeComponentClass({ + validAttributes: RKTextViewAttributes, + uiViewClassName: 'RCTTextView', +}); + +var RKTextField = createReactIOSNativeComponentClass({ + validAttributes: RKTextFieldAttributes, + uiViewClassName: 'RCTTextField', +}); + +module.exports = TextInput; diff --git a/Libraries/Components/TextInput/TextInputState.js b/Libraries/Components/TextInput/TextInputState.js new file mode 100644 index 0000000000..a8c80ee66e --- /dev/null +++ b/Libraries/Components/TextInput/TextInputState.js @@ -0,0 +1,53 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule TextInputState + * + * This class is responsible for coordinating the "focused" + * state for TextInputs. All calls relating to the keyboard + * should be funneled through here + */ +'use strict'; + +var RKUIManager = require('NativeModulesDeprecated').RKUIManager; + +var TextInputState = { + /** + * Internal state + */ + _currentlyFocusedID: null, + + /** + * Returns the ID of the currently focused text field, if one exists + * If no text field is focused it returns null + */ + currentlyFocusedField: function() { + return this._currentlyFocusedID; + }, + + /** + * @param {string} TextInputID id of the text field to focus + * Focuses the specified text field + * noop if the text field was already focused + */ + focusTextInput: function(textFieldID) { + if (this._currentlyFocusedID != textFieldID && textFieldID != null) { + this._currentlyFocusedID = textFieldID; + RKUIManager.focus(textFieldID); + } + }, + + /** + * @param {string} textFieldID id of the text field to focus + * Unfocuses the specified text field + * noop if it wasn't focused + */ + blurTextInput: function(textFieldID) { + if (this._currentlyFocusedID == textFieldID && textFieldID != null) { + this._currentlyFocusedID = null; + RKUIManager.blur(textFieldID); + } + } +}; + +module.exports = TextInputState; diff --git a/Libraries/Components/Touchable/TouchableHighlight.js b/Libraries/Components/Touchable/TouchableHighlight.js new file mode 100644 index 0000000000..da721cff8a --- /dev/null +++ b/Libraries/Components/Touchable/TouchableHighlight.js @@ -0,0 +1,197 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule TouchableHighlight + */ +'use strict'; + +var NativeMethodsMixin = require('NativeMethodsMixin'); +var React = require('React'); +var ReactIOSViewAttributes = require('ReactIOSViewAttributes'); +var StyleSheet = require('StyleSheet'); +var TimerMixin = require('TimerMixin'); +var Touchable = require('Touchable'); +var View = require('View'); + +var cloneWithProps = require('cloneWithProps'); +var ensureComponentIsNative = require('ensureComponentIsNative'); +var keyOf = require('keyOf'); +var merge = require('merge'); +var onlyChild = require('onlyChild'); + +/** + * TouchableHighlight - A wrapper for making views respond properly to touches. + * On press down, the opacity of the wrapped view is decreased, which allows + * the underlay color to show through, darkening or tinting the view. The + * underlay comes from adding a view to the view hierarchy, which can sometimes + * cause unwanted visual artifacts if not used correctly, for example if the + * backgroundColor of the wrapped view isn't explicitly set to an opaque color. + * Example: + * + * renderButton: function() { + * return ( + * + * + * + * ); + * }, + * + * More example code in TouchableExample.js, and more in-depth discussion in + * Touchable.js. See also TouchableWithoutFeedback.js. + */ + +var DEFAULT_PROPS = { + activeOpacity: 0.8, + underlayColor: 'black', +}; + +var TouchableHighlight = React.createClass({ + propTypes: { + /** + * Called when the touch is released, but not if cancelled (e.g. by + * a scroll that steals the responder lock). + */ + onPress: React.PropTypes.func.isRequired, + /** + * Determines what the opacity of the wrapped view should be when touch is + * active. + */ + activeOpacity: React.PropTypes.number, + /** + * The color of the underlay that will show through when the touch is + * active. + */ + underlayColor: React.PropTypes.string, + style: View.stylePropType, + }, + + mixins: [NativeMethodsMixin, TimerMixin, Touchable.Mixin], + + getDefaultProps: () => DEFAULT_PROPS, + + // Performance optimization to avoid constantly re-generating these objects. + computeSyntheticState: function(props) { + return { + activeProps: { + style: { + opacity: props.activeOpacity, + } + }, + activeUnderlayProps: { + style: { + backgroundColor: props.underlayColor, + } + }, + underlayStyle: [ + INACTIVE_UNDERLAY_PROPS.style, + props.style, + ] + }; + }, + + getInitialState: function() { + return merge( + this.touchableGetInitialState(), this.computeSyntheticState(this.props) + ); + }, + + componentDidMount: function() { + ensureComponentIsNative(this.refs[CHILD_REF]); + }, + + componentDidUpdate: function() { + ensureComponentIsNative(this.refs[CHILD_REF]); + }, + + componentWillReceiveProps: function(nextProps) { + if (nextProps.activeOpacity !== this.props.activeOpacity || + nextProps.underlayColor !== this.props.underlayColor || + nextProps.style !== this.props.style) { + this.setState(this.computeSyntheticState(nextProps)); + } + }, + + viewConfig: { + uiViewClassName: 'RCTView', + validAttributes: ReactIOSViewAttributes.RKView + }, + + /** + * `Touchable.Mixin` self callbacks. The mixin will invoke these if they are + * defined on your component. + */ + touchableHandleActivePressIn: function() { + this.clearTimeout(this._hideTimeout); + this._hideTimeout = null; + this._showUnderlay(); + }, + + touchableHandleActivePressOut: function() { + if (!this._hideTimeout) { + this._hideUnderlay(); + } + }, + + touchableHandlePress: function() { + this.clearTimeout(this._hideTimeout); + this._showUnderlay(); + this._hideTimeout = this.setTimeout(this._hideUnderlay, 100); + this.props.onPress && this.props.onPress(); + }, + + touchableGetPressRectOffset: function() { + return PRESS_RECT_OFFSET; // Always make sure to predeclare a constant! + }, + + _showUnderlay: function() { + this.refs[UNDERLAY_REF].setNativeProps(this.state.activeUnderlayProps); + this.refs[CHILD_REF].setNativeProps(this.state.activeProps); + }, + + _hideUnderlay: function() { + this.clearTimeout(this._hideTimeout); + this._hideTimeout = null; + if (this.refs[UNDERLAY_REF]) { + this.refs[CHILD_REF].setNativeProps(INACTIVE_CHILD_PROPS); + this.refs[UNDERLAY_REF].setNativeProps(INACTIVE_UNDERLAY_PROPS); + } + }, + + render: function() { + return ( + + {cloneWithProps( + onlyChild(this.props.children), + { + ref: CHILD_REF, + accessible: true, + testID: this.props.testID, + } + )} + + ); + } +}); + +var PRESS_RECT_OFFSET = {top: 20, left: 20, right: 20, bottom: 30}; +var CHILD_REF = keyOf({childRef: null}); +var UNDERLAY_REF = keyOf({underlayRef: null}); +var INACTIVE_CHILD_PROPS = { + style: StyleSheet.create({x: {opacity: 1.0}}).x, +}; +var INACTIVE_UNDERLAY_PROPS = { + style: StyleSheet.create({x: {backgroundColor: 'transparent'}}).x, +}; + +module.exports = TouchableHighlight; diff --git a/Libraries/Components/Touchable/TouchableOpacity.js b/Libraries/Components/Touchable/TouchableOpacity.js new file mode 100644 index 0000000000..a1bd8f4e35 --- /dev/null +++ b/Libraries/Components/Touchable/TouchableOpacity.js @@ -0,0 +1,144 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule TouchableOpacity + */ +'use strict'; + +var NativeMethodsMixin = require('NativeMethodsMixin'); +var POPAnimationMixin = require('POPAnimationMixin'); +var React = require('React'); +var Touchable = require('Touchable'); + +var cloneWithProps = require('cloneWithProps'); +var ensureComponentIsNative = require('ensureComponentIsNative'); +var keyOf = require('keyOf'); +var onlyChild = require('onlyChild'); + +/** + * TouchableOpacity - A wrapper for making views respond properly to touches. + * On press down, the opacity of the wrapped view is decreased, dimming it. + * This is done without actually changing the view hierarchy, and in general is + * easy to add to an app without weird side-effects. Example: + * + * renderButton: function() { + * return ( + * + * + * + * ); + * }, + * + * More example code in TouchableExample.js, and more in-depth discussion in + * Touchable.js. See also TouchableHighlight.js and + * TouchableWithoutFeedback.js. + */ + +var TouchableOpacity = React.createClass({ + mixins: [Touchable.Mixin, NativeMethodsMixin, POPAnimationMixin], + + propTypes: { + /** + * Called when the touch is released, but not if cancelled (e.g. by + * a scroll that steals the responder lock). + */ + onPress: React.PropTypes.func, + /** + * Determines what the opacity of the wrapped view should be when touch is + * active. + */ + activeOpacity: React.PropTypes.number, + }, + + getDefaultProps: function() { + return { + activeOpacity: 0.5, + }; + }, + + getInitialState: function() { + return this.touchableGetInitialState(); + }, + + componentDidMount: function() { + ensureComponentIsNative(this.refs[CHILD_REF]); + }, + + componentDidUpdate: function() { + ensureComponentIsNative(this.refs[CHILD_REF]); + }, + + setOpacityTo: function(value) { + if (POPAnimationMixin) { + // Reset with animation if POP is available + this.stopAllAnimations(); + var anim = { + type: this.AnimationTypes.linear, + property: this.AnimationProperties.opacity, + toValue: value, + }; + this.startAnimation(CHILD_REF, anim); + } else { + // Reset immediately if POP is unavailable + this.refs[CHILD_REF].setNativeProps({ + opacity: value + }); + } + }, + + /** + * `Touchable.Mixin` self callbacks. The mixin will invoke these if they are + * defined on your component. + */ + touchableHandleActivePressIn: function() { + this.refs[CHILD_REF].setNativeProps({ + opacity: this.props.activeOpacity + }); + }, + + touchableHandleActivePressOut: function() { + this.setOpacityTo(1.0); + }, + + touchableHandlePress: function() { + this.setOpacityTo(1.0); + this.props.onPress && this.props.onPress(); + }, + + touchableGetPressRectOffset: function() { + return PRESS_RECT_OFFSET; // Always make sure to predeclare a constant! + }, + + touchableGetHighlightDelayMS: function() { + return 0; + }, + + render: function() { + return cloneWithProps(onlyChild(this.props.children), { + ref: CHILD_REF, + accessible: true, + testID: this.props.testID, + onStartShouldSetResponder: this.touchableHandleStartShouldSetResponder, + onResponderTerminationRequest: this.touchableHandleResponderTerminationRequest, + onResponderGrant: this.touchableHandleResponderGrant, + onResponderMove: this.touchableHandleResponderMove, + onResponderRelease: this.touchableHandleResponderRelease, + onResponderTerminate: this.touchableHandleResponderTerminate, + }); + }, +}); + +/** + * When the scroll view is disabled, this defines how far your touch may move + * off of the button, before deactivating the button. Once deactivated, try + * moving it back and you'll see that the button is once again reactivated! + * Move it back and forth several times while the scroll view is disabled. + */ +var PRESS_RECT_OFFSET = {top: 20, left: 20, right: 20, bottom: 30}; + +var CHILD_REF = keyOf({childRef: null}); + +module.exports = TouchableOpacity; diff --git a/Libraries/Components/Touchable/TouchableWithoutFeedback.js b/Libraries/Components/Touchable/TouchableWithoutFeedback.js new file mode 100644 index 0000000000..66a82d59c7 --- /dev/null +++ b/Libraries/Components/Touchable/TouchableWithoutFeedback.js @@ -0,0 +1,88 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule TouchableWithoutFeedback + */ +'use strict'; + +var React = require('React'); +var Touchable = require('Touchable'); +var View = require('View'); + +var copyProperties = require('copyProperties'); +var onlyChild = require('onlyChild'); + +/** + * When the scroll view is disabled, this defines how far your touch may move + * off of the button, before deactivating the button. Once deactivated, try + * moving it back and you'll see that the button is once again reactivated! + * Move it back and forth several times while the scroll view is disabled. + */ +var PRESS_RECT_OFFSET = {top: 20, left: 20, right: 20, bottom: 30}; + + +/** + * Do not use unless you have a very good reason. All the elements that + * respond to press should have a visual feedback when touched. This is + * one of the primary reason a "web" app doesn't feel "native". + */ +var TouchableWithoutFeedback = React.createClass({ + mixins: [Touchable.Mixin], + + propTypes: { + onPress: React.PropTypes.func, + onPressIn: React.PropTypes.func, + onPressOut: React.PropTypes.func, + onLongPress: React.PropTypes.func, + }, + + getInitialState: function() { + return this.touchableGetInitialState(); + }, + + /** + * `Touchable.Mixin` self callbacks. The mixin will invoke these if they are + * defined on your component. + */ + touchableHandlePress: function(e) { + this.props.onPress && this.props.onPress(e); + }, + + touchableHandleActivePressIn: function() { + this.props.onPressIn && this.props.onPressIn(); + }, + + touchableHandleActivePressOut: function() { + this.props.onPressOut && this.props.onPressOut(); + }, + + touchableHandleLongPress: function() { + this.props.onLongPress && this.props.onLongPress(); + }, + + touchableGetPressRectOffset: function() { + return PRESS_RECT_OFFSET; // Always make sure to predeclare a constant! + }, + + touchableGetHighlightDelayMS: function() { + return 0; + }, + + render: function() { + // Note(vjeux): use cloneWithProps once React has been upgraded + var child = onlyChild(this.props.children); + copyProperties(child.props, { + accessible: true, + testID: this.props.testID, + onStartShouldSetResponder: this.touchableHandleStartShouldSetResponder, + onResponderTerminationRequest: this.touchableHandleResponderTerminationRequest, + onResponderGrant: this.touchableHandleResponderGrant, + onResponderMove: this.touchableHandleResponderMove, + onResponderRelease: this.touchableHandleResponderRelease, + onResponderTerminate: this.touchableHandleResponderTerminate + }); + return child; + } +}); + +module.exports = TouchableWithoutFeedback; diff --git a/Libraries/Components/Touchable/ensureComponentIsNative.js b/Libraries/Components/Touchable/ensureComponentIsNative.js new file mode 100644 index 0000000000..c16d93e3d2 --- /dev/null +++ b/Libraries/Components/Touchable/ensureComponentIsNative.js @@ -0,0 +1,18 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ensureComponentIsNative + */ +'use strict'; + +var invariant = require('invariant'); + +var ensureComponentIsNative = function(component) { + invariant( + component && typeof component.setNativeProps === 'function', + 'Touchable child must either be native or forward setNativeProps to a ' + + 'native component' + ); +}; + +module.exports = ensureComponentIsNative; diff --git a/Libraries/Components/View/View.js b/Libraries/Components/View/View.js new file mode 100644 index 0000000000..643eef68a3 --- /dev/null +++ b/Libraries/Components/View/View.js @@ -0,0 +1,157 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule View + */ +'use strict'; + +var NativeMethodsMixin = require('NativeMethodsMixin'); +var NativeModules = require('NativeModules'); +var PropTypes = require('ReactPropTypes'); +var React = require('React'); +var ReactIOSViewAttributes = require('ReactIOSViewAttributes'); +var StyleSheetPropType = require('StyleSheetPropType'); +var ViewStylePropTypes = require('ViewStylePropTypes'); + +/** + * - The most fundamental component for building UI, `View` is a + * container that supports layout with flexbox, style, some touch handling, and + * accessibility controls, and is designed to be nested inside other views and + * to have 0 to many children of any type. `View` maps directly to the native + * view equivalent on whatever platform react is running on, whether that is a + * `UIView`, `
`, `android.view`, etc. This example creates a `View` that + * wraps two colored boxes and custom component in a row with padding. + * + * + * + * + * + * + * + * By default, `View`s have a primary flex direction of 'column', so children + * will stack up vertically by default. `View`s also expand to fill the parent + * in the direction of the parent's flex direction by default, so in the case of + * a default parent (flexDirection: 'column'), the children will fill the width, + * but not the height. + * + * Many library components can be treated like plain `Views` in many cases, for + * example passing them children, setting style, etc. + * + * `View`s are designed to be used with `StyleSheet`s for clarity and + * performance, although inline styles are also supported. It is common for + * `StyleSheet`s to be combined dynamically. See `StyleSheet.js` for more info. + * + * Check out `ViewExample.js`, `LayoutExample.js`, and other apps for more code + * examples. + */ + +var StyleConstants = NativeModules.RKUIManager.StyleConstants; + +var createReactIOSNativeComponentClass = require('createReactIOSNativeComponentClass'); + +var stylePropType = StyleSheetPropType(ViewStylePropTypes); + +var View = React.createClass({ + statics: { + pointerEvents: StyleConstants.PointerEventsValues, + stylePropType, + }, + + mixins: [NativeMethodsMixin], + + /** + * `NativeMethodsMixin` will look for this when invoking `setNativeProps`. We + * make `this` look like an actual native component class. + */ + viewConfig: { + uiViewClassName: 'RCTView', + validAttributes: ReactIOSViewAttributes.RKView + }, + + propTypes: { + /** + * When true, indicates that the view is an accessibility element + */ + accessible: PropTypes.bool, + + /** + * This string can be used to identify the accessible element. + */ + testID: PropTypes.string, + + /** + * For most touch interactions, you'll simply want to wrap your component in + * `TouchableHighlight.js`. Check out `Touchable.js` and + * `ScrollResponder.js` for more discussion. + */ + onResponderGrant: PropTypes.func, + onResponderReject: PropTypes.func, + onResponderMove: PropTypes.func, + onResponderRelease: PropTypes.func, + onResponderTerminate: PropTypes.func, + onResponderTerminationRequest: PropTypes.func, + onMoveShouldSetResponder: PropTypes.func, + onStartShouldSetResponder: PropTypes.func, + onStartShouldSetResponderCapture: PropTypes.func, + + /** + * In the absence of `auto` property, `none` is much like `CSS`'s `none` + * value. `boxNone` is as if you had applied the `CSS` class: + * + * .cantTouchThis * { + * pointer-events: auto; + * } + * .cantTouchThis { + * pointer-events: none; + * } + * + * But since `pointerEvents` does not affect layout/appearance, and we are + * already deviating from the spec by adding additional modes, we opt to not + * include `pointerEvents` on `style`. On some platforms, we would need to + * implement it as a `className` anyways. Using `style` or not is an + * implementation detail of the platform. + */ + pointerEvents: PropTypes.oneOf([ + StyleConstants.PointerEventsValues.boxNone, + StyleConstants.PointerEventsValues.none, + StyleConstants.PointerEventsValues.boxOnly, + StyleConstants.PointerEventsValues.unspecified + ]), + + /** + * Used to style and layout the `View`. See `StyleSheet.js` and + * `ViewStylePropTypes.js` for more info. + */ + style: stylePropType, + + /** + * This is a special performance property exposed by RKView and is useful + * for scrolling content when there are many subviews, most of which are + * offscreen. For this property to be effective, it must be applied to a + * view that contains many subviews that extend outside its bound. The + * subviews must also have overflow: hidden, as should the containing view + * (or one of its superviews). + */ + removeClippedSubviews: PropTypes.bool, + }, + + render: function() { + return ; + }, +}); + + +var RKView = createReactIOSNativeComponentClass({ + validAttributes: ReactIOSViewAttributes.RKView, + uiViewClassName: 'RCTView', +}); + +var ViewToExport = RKView; +if (__DEV__) { + ViewToExport = View; +} + +ViewToExport.pointerEvents = View.pointerEvents; +ViewToExport.stylePropType = stylePropType; + +module.exports = ViewToExport; diff --git a/Libraries/Components/View/ViewStylePropTypes.js b/Libraries/Components/View/ViewStylePropTypes.js new file mode 100644 index 0000000000..c049c3d536 --- /dev/null +++ b/Libraries/Components/View/ViewStylePropTypes.js @@ -0,0 +1,41 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ViewStylePropTypes + */ +'use strict'; + +var LayoutPropTypes = require('LayoutPropTypes'); +var ReactPropTypes = require('ReactPropTypes'); + +var merge = require('merge'); + +/** + * Warning: Some of these properties may not be supported in all releases. + */ +var ViewStylePropTypes = merge( + LayoutPropTypes, { + backgroundColor: ReactPropTypes.string, + borderColor: ReactPropTypes.string, + borderTopColor: ReactPropTypes.string, + borderRightColor: ReactPropTypes.string, + borderBottomColor: ReactPropTypes.string, + borderLeftColor: ReactPropTypes.string, + borderRadius: ReactPropTypes.number, + opacity: ReactPropTypes.number, + overflow: ReactPropTypes.oneOf(['visible', 'hidden']), + shadowColor: ReactPropTypes.string, + shadowOffset: ReactPropTypes.shape( + {h: ReactPropTypes.number, w: ReactPropTypes.number} + ), + shadowOpacity: ReactPropTypes.number, + shadowRadius: ReactPropTypes.number, + transformMatrix: ReactPropTypes.arrayOf(ReactPropTypes.number), + rotation: ReactPropTypes.number, + scaleX: ReactPropTypes.number, + scaleY: ReactPropTypes.number, + translateX: ReactPropTypes.number, + translateY: ReactPropTypes.number, +}); + +module.exports = ViewStylePropTypes; diff --git a/Libraries/Device/RCTDeviceEventEmitter.js b/Libraries/Device/RCTDeviceEventEmitter.js new file mode 100644 index 0000000000..adb9d3498c --- /dev/null +++ b/Libraries/Device/RCTDeviceEventEmitter.js @@ -0,0 +1,12 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule RCTDeviceEventEmitter + */ +'use strict'; + +var EventEmitter = require('EventEmitter'); + +var RCTDeviceEventEmitter = new EventEmitter(); + +module.exports = RCTDeviceEventEmitter; diff --git a/Libraries/Fetch/fetch.js b/Libraries/Fetch/fetch.js new file mode 100644 index 0000000000..1ffa8c1f62 --- /dev/null +++ b/Libraries/Fetch/fetch.js @@ -0,0 +1,313 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * This is a third-party polyfill grabbed from: + * https://github.com/github/fetch + * + * @providesModule fetch + * @nolint + */ +'use strict'; + +var self = {}; + +/** + * Copyright (c) 2014 GitHub, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * @preserve-header + */ +(function() { + 'use strict'; + + if (self.fetch) { + return + } + + function Headers(headers) { + this.map = {} + + var self = this + if (headers instanceof Headers) { + headers.forEach(function(name, values) { + values.forEach(function(value) { + self.append(name, value) + }) + }) + + } else if (headers) { + Object.getOwnPropertyNames(headers).forEach(function(name) { + self.append(name, headers[name]) + }) + } + } + + Headers.prototype.append = function(name, value) { + name = name.toLowerCase() + var list = this.map[name] + if (!list) { + list = [] + this.map[name] = list + } + list.push(value) + } + + Headers.prototype['delete'] = function(name) { + delete this.map[name.toLowerCase()] + } + + Headers.prototype.get = function(name) { + var values = this.map[name.toLowerCase()] + return values ? values[0] : null + } + + Headers.prototype.getAll = function(name) { + return this.map[name.toLowerCase()] || [] + } + + Headers.prototype.has = function(name) { + return this.map.hasOwnProperty(name.toLowerCase()) + } + + Headers.prototype.set = function(name, value) { + this.map[name.toLowerCase()] = [value] + } + + // Instead of iterable for now. + Headers.prototype.forEach = function(callback) { + var self = this + Object.getOwnPropertyNames(this.map).forEach(function(name) { + callback(name, self.map[name]) + }) + } + + function consumed(body) { + if (body.bodyUsed) { + return Promise.reject(new TypeError('Already read')) + } + body.bodyUsed = true + } + + function fileReaderReady(reader) { + return new Promise(function(resolve, reject) { + reader.onload = function() { + resolve(reader.result) + } + reader.onerror = function() { + reject(reader.error) + } + }) + } + + function readBlobAsArrayBuffer(blob) { + var reader = new FileReader() + reader.readAsArrayBuffer(blob) + return fileReaderReady(reader) + } + + function readBlobAsText(blob) { + var reader = new FileReader() + reader.readAsText(blob) + return fileReaderReady(reader) + } + + var blobSupport = 'FileReader' in self && 'Blob' in self && (function() { + try { + new Blob(); + return true + } catch(e) { + return false + } + })(); + + function Body() { + this.bodyUsed = false + + if (blobSupport) { + this.blob = function() { + var rejected = consumed(this) + return rejected ? rejected : Promise.resolve(this._bodyBlob) + } + + this.arrayBuffer = function() { + return this.blob().then(readBlobAsArrayBuffer) + } + + this.text = function() { + return this.blob().then(readBlobAsText) + } + } else { + this.text = function() { + var rejected = consumed(this) + return rejected ? rejected : Promise.resolve(this._bodyText) + } + } + + if ('FormData' in self) { + this.formData = function() { + return this.text().then(decode) + } + } + + this.json = function() { + return this.text().then(JSON.parse) + } + + return this + } + + // HTTP methods whose capitalization should be normalized + var methods = ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT'] + + function normalizeMethod(method) { + var upcased = method.toUpperCase() + return (methods.indexOf(upcased) > -1) ? upcased : method + } + + function Request(url, options) { + options = options || {} + this.url = url + this._body = options.body + this.credentials = options.credentials || 'omit' + this.headers = new Headers(options.headers) + this.method = normalizeMethod(options.method || 'GET') + this.mode = options.mode || null + this.referrer = null + } + + function decode(body) { + var form = new FormData() + body.trim().split('&').forEach(function(bytes) { + if (bytes) { + var split = bytes.split('=') + var name = split.shift().replace(/\+/g, ' ') + var value = split.join('=').replace(/\+/g, ' ') + form.append(decodeURIComponent(name), decodeURIComponent(value)) + } + }) + return form + } + + function headers(xhr) { + var head = new Headers() + var pairs = xhr.getAllResponseHeaders().trim().split('\n') + pairs.forEach(function(header) { + var split = header.trim().split(':') + var key = split.shift().trim() + var value = split.join(':').trim() + head.append(key, value) + }) + return head + } + + Request.prototype.fetch = function() { + var self = this + + return new Promise(function(resolve, reject) { + var xhr = new XMLHttpRequest() + + function responseURL() { + if ('responseURL' in xhr) { + return xhr.responseURL + } + + // Avoid security warnings on getResponseHeader when not allowed by CORS + if (/^X-Request-URL:/m.test(xhr.getAllResponseHeaders())) { + return xhr.getResponseHeader('X-Request-URL') + } + + return; + } + + xhr.onload = function() { + var status = (xhr.status === 1223) ? 204 : xhr.status + if (status < 100 || status > 599) { + reject(new TypeError('Network request failed')) + return + } + var options = { + status: status, + statusText: xhr.statusText, + headers: headers(xhr), + url: responseURL() + } + var body = 'response' in xhr ? xhr.response : xhr.responseText; + resolve(new Response(body, options)) + } + + xhr.onerror = function() { + reject(new TypeError('Network request failed')) + } + + xhr.open(self.method, self.url) + if ('responseType' in xhr && blobSupport) { + xhr.responseType = 'blob' + } + + self.headers.forEach(function(name, values) { + values.forEach(function(value) { + xhr.setRequestHeader(name, value) + }) + }) + + xhr.send((self._body === undefined) ? null : self._body) + }) + } + + Body.call(Request.prototype) + + function Response(bodyInit, options) { + if (!options) { + options = {} + } + + if (blobSupport) { + if (typeof bodyInit === 'string') { + this._bodyBlob = new Blob([bodyInit]) + } else { + this._bodyBlob = bodyInit + } + } else { + this._bodyText = bodyInit + } + this.type = 'default' + this.url = null + this.status = options.status + this.statusText = options.statusText + this.headers = options.headers + this.url = options.url || '' + } + + Body.call(Response.prototype) + + self.Headers = Headers; + self.Request = Request; + self.Response = Response; + + self.fetch = function (url, options) { + return new Request(url, options).fetch() + } + self.fetch.polyfill = true +})(); + +/** End of the third-party code */ + +module.exports = self.fetch; diff --git a/Libraries/Interaction/InteractionManager.js b/Libraries/Interaction/InteractionManager.js new file mode 100644 index 0000000000..3e895f3b06 --- /dev/null +++ b/Libraries/Interaction/InteractionManager.js @@ -0,0 +1,143 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule InteractionManager + */ +'use strict'; + +var ErrorUtils = require('ErrorUtils'); +var EventEmitter = require('EventEmitter'); +var Set = require('Set'); + +var invariant = require('invariant'); +var keyMirror = require('keyMirror'); +var setImmediate = require('setImmediate'); + +/** + * InteractionManager allows long-running work to be scheduled after any + * interactions/animations have completed. In particular, this allows JavaScript + * animations to run smoothly. + * + * Applications can schedule tasks to run after interactions with the following: + * + * InteractionManager.runAfterInteractions(() => { + * // ...long-running synchronous task... + * }); + * + * Compare this to other scheduling alternatives: + * - requestAnimationFrame(): for code that animates a view over time. + * - setImmediate/setTimeout(): run code later, note this may delay animations. + * - runAfterInteractions(): run code later, without delaying active animations. + * + * The touch handling system considers one or more active touches to be an + * 'interaction' and will delay `runAfterInteractions()` callbacks until all + * touches have ended or been cancelled. + * + * InteractionManager also allows applications to register animations by + * creating an interaction 'handle' on animation start, and clearing it upon + * completion: + * + * var handle = InteractionManager.createInteractionHandle(); + * // run animation... (`runAfterInteractions` tasks are queued) + * // later, on animation completion: + * InteractionManager.clearInteractionHandle(handle); + * // queued tasks run if all handles were cleared + */ + +var _emitter = new EventEmitter(); +var _interactionSet = new Set(); +var _addInteractionSet = new Set(); +var _deleteInteractionSet = new Set(); +var _nextUpdateHandle = null; +var _queue = []; +var _inc = 0; + +var InteractionManager = { + Events: keyMirror({ + interactionStart: true, + interactionComplete: true, + }), + + /** + * Notify manager that an interaction has started. + */ + createInteractionHandle() { + scheduleUpdate(); + var handle = ++_inc; + _addInteractionSet.add(handle); + return handle; + }, + + /** + * Notify manager that an interaction has completed. + */ + clearInteractionHandle(handle) { + invariant( + !!handle, + 'Must provide a handle to clear.' + ); + scheduleUpdate(); + _addInteractionSet.delete(handle); + _deleteInteractionSet.add(handle); + }, + + /** + * Schedule a function to run after all interactions have completed. + * + * @param {function} callback + */ + runAfterInteractions(callback) { + invariant( + typeof callback === 'function', + 'Must specify a function to schedule.' + ); + scheduleUpdate(); + _queue.push(callback); + }, + + addListener: _emitter.addListener.bind(_emitter), +}; + +/** + * Schedule an asynchronous update to the interaction state. + */ +function scheduleUpdate() { + if (!_nextUpdateHandle) { + _nextUpdateHandle = setImmediate(processUpdate); + } +} + +/** + * Notify listeners, process queue, etc + */ +function processUpdate() { + var interactionCount = _interactionSet.size; + _addInteractionSet.forEach(handle => + _interactionSet.add(handle) + ); + _deleteInteractionSet.forEach(handle => + _interactionSet.delete(handle) + ); + var nextInteractionCount = _interactionSet.size; + + if (interactionCount !== 0 && nextInteractionCount === 0) { + // transition from 1+ --> 0 interactions + _emitter.emit(InteractionManager.Events.interactionComplete); + } else if (interactionCount === 0 && nextInteractionCount !== 0) { + // transition from 0 --> 1+ interactions + _emitter.emit(InteractionManager.Events.interactionStart); + } + + // process the queue regardless of a transition + if (nextInteractionCount === 0) { + _queue.forEach(callback => { + ErrorUtils.applyWithGuard(callback); + }); + _queue = []; + } + _nextUpdateHandle = null; + _addInteractionSet.clear(); + _deleteInteractionSet.clear(); +} + +module.exports = InteractionManager; diff --git a/Libraries/JavaScriptAppEngine/Initialization/InitializeJavaScriptAppEngine.js b/Libraries/JavaScriptAppEngine/Initialization/InitializeJavaScriptAppEngine.js new file mode 100644 index 0000000000..b529460ee8 --- /dev/null +++ b/Libraries/JavaScriptAppEngine/Initialization/InitializeJavaScriptAppEngine.js @@ -0,0 +1,147 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * Sets up global variables typical in most JavaScript environments. + * + * 1. Global timers (via `setTimeout` etc). + * 2. Global console object. + * 3. Hooks for printing stack traces with source maps. + * + * Leaves enough room in the environment for implementing your own: + * 1. Require system. + * 2. Bridged modules. + * + * @providesModule InitializeJavaScriptAppEngine + */ + +/* eslint global-strict: 0 */ +/* globals GLOBAL: true, window: true */ + +var JSTimers = require('JSTimers'); + +// Just to make sure the JS gets packaged up +require('RCTDeviceEventEmitter'); +var ErrorUtils = require('ErrorUtils'); +var RKAlertManager = require('RKAlertManager'); +var RKExceptionsManager = require('NativeModules').RKExceptionsManager; + +var errorToString = require('errorToString'); +var loadSourceMap = require('loadSourceMap'); + +if (typeof GLOBAL === 'undefined') { + GLOBAL = this; +} + +if (typeof window === 'undefined') { + window = GLOBAL; +} + +function handleErrorWithRedBox(e) { + GLOBAL.console.error( + 'Error: ' + + '\n stack: \n' + e.stack + + '\n URL: ' + e.sourceURL + + '\n line: ' + e.line + + '\n message: ' + e.message + ); + + if (RKExceptionsManager) { + RKExceptionsManager.reportUnhandledException(e.message, errorToString(e)); + if (__DEV__) { + try { + var sourceMapInstance = loadSourceMap(); + var prettyStack = errorToString(e, sourceMapInstance); + RKExceptionsManager.updateExceptionMessage(e.message, prettyStack); + } catch (ee) { + GLOBAL.console.error('#CLOWNTOWN (error while displaying error): ' + ee.message); + } + } + } +} + +function setupRedBoxErrorHandler() { + ErrorUtils.setGlobalHandler(handleErrorWithRedBox); +} + +function setupDocumentShim() { + // The browser defines Text and Image globals by default. If you forget to + // require them, then the error message is very confusing. + function getInvalidGlobalUseError(name) { + return new Error( + 'You are trying to render the global ' + name + ' variable as a ' + + 'React element. You probably forgot to require ' + name + '.' + ); + } + GLOBAL.Text = { + get defaultProps() { + throw getInvalidGlobalUseError('Text'); + } + }; + GLOBAL.Image = { + get defaultProps() { + throw getInvalidGlobalUseError('Image'); + } + }; + + GLOBAL.document = { + // This shouldn't be needed but scroller library fails without it. If + // we fixed the scroller, we wouldn't need this. + body: {}, + // Workaround for setImmediate + createElement: function() {return {};} + }; +} + +/** + * Sets up a set of window environment wrappers that ensure that the + * BatchedBridge is flushed after each tick. In both the case of the + * `UIWebView` based `RKJavaScriptCaller` and `RKContextCaller`, we + * implement our own custom timing bridge that should be immune to + * unexplainably dropped timing signals. + */ +function setupTimers() { + GLOBAL.setTimeout = JSTimers.setTimeout; + GLOBAL.setInterval = JSTimers.setInterval; + GLOBAL.setImmediate = JSTimers.setImmediate; + GLOBAL.clearTimeout = JSTimers.clearTimeout; + GLOBAL.clearInterval = JSTimers.clearInterval; + GLOBAL.clearImmediate = JSTimers.clearImmediate; + GLOBAL.cancelAnimationFrame = JSTimers.clearInterval; + GLOBAL.requestAnimationFrame = function(cb) { + /*requestAnimationFrame() { [native code] };*/ // Trick scroller library + return JSTimers.requestAnimationFrame(cb); // into thinking it's native + }; +} + +function setupAlert() { + if (!GLOBAL.alert) { + GLOBAL.alert = function(text) { + var alertOpts = { + title: 'Alert', + message: '' + text, + buttons: [{'cancel': 'Okay'}], + }; + RKAlertManager.alertWithArgs(alertOpts, null); + }; + } +} + +function setupPromise() { + // The native Promise implementation throws the following error: + // ERROR: Event loop not supported. + GLOBAL.Promise = require('Promise'); +} + +function setupXHR() { + // The native XMLHttpRequest in Chrome dev tools is CORS aware and won't + // let you fetch anything from the internet + GLOBAL.XMLHttpRequest = require('XMLHttpRequest'); + GLOBAL.fetch = require('fetch'); +} + +setupRedBoxErrorHandler(); +setupDocumentShim(); +setupTimers(); +setupAlert(); +setupPromise(); +setupXHR(); diff --git a/Libraries/JavaScriptAppEngine/Initialization/SourceMap.js b/Libraries/JavaScriptAppEngine/Initialization/SourceMap.js new file mode 100644 index 0000000000..75fe5de247 --- /dev/null +++ b/Libraries/JavaScriptAppEngine/Initialization/SourceMap.js @@ -0,0 +1,1958 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule SourceMap + * @generated + * + * This module was generated from `node_modules/source-map` by running + * + * $ npm install dryice + * $ node Makefile.dryice.js + * $ cat dist/source-map.js + * + * and wrapping resulting file into `wrapper` function. + * + */ + +var scope = {}; +wrapper.call(scope); + +module.exports = scope.sourceMap; + +function wrapper() { + +/* -*- Mode: js; js-indent-level: 2; -*- */ +/* + * Copyright 2011 Mozilla Foundation and contributors + * Licensed under the New BSD license. See LICENSE or: + * http://opensource.org/licenses/BSD-3-Clause + */ + +/** + * Define a module along with a payload. + * @param {string} moduleName Name for the payload + * @param {ignored} deps Ignored. For compatibility with CommonJS AMD Spec + * @param {function} payload Function with (require, exports, module) params + */ +function define(moduleName, deps, payload) { + if (typeof moduleName != "string") { + throw new TypeError('Expected string, got: ' + moduleName); + } + + if (arguments.length == 2) { + payload = deps; + } + + if (moduleName in define.modules) { + throw new Error("Module already defined: " + moduleName); + } + define.modules[moduleName] = payload; +}; + +/** + * The global store of un-instantiated modules + */ +define.modules = {}; + + +/** + * We invoke require() in the context of a Domain so we can have multiple + * sets of modules running separate from each other. + * This contrasts with JSMs which are singletons, Domains allows us to + * optionally load a CommonJS module twice with separate data each time. + * Perhaps you want 2 command lines with a different set of commands in each, + * for example. + */ +function Domain() { + this.modules = {}; + this._currentModule = null; +} + +(function () { + + /** + * Lookup module names and resolve them by calling the definition function if + * needed. + * There are 2 ways to call this, either with an array of dependencies and a + * callback to call when the dependencies are found (which can happen + * asynchronously in an in-page context) or with a single string an no callback + * where the dependency is resolved synchronously and returned. + * The API is designed to be compatible with the CommonJS AMD spec and + * RequireJS. + * @param {string[]|string} deps A name, or names for the payload + * @param {function|undefined} callback Function to call when the dependencies + * are resolved + * @return {undefined|object} The module required or undefined for + * array/callback method + */ + Domain.prototype.require = function(deps, callback) { + if (Array.isArray(deps)) { + var params = deps.map(function(dep) { + return this.lookup(dep); + }, this); + if (callback) { + callback.apply(null, params); + } + return undefined; + } + else { + return this.lookup(deps); + } + }; + + function normalize(path) { + var bits = path.split('/'); + var i = 1; + while (i < bits.length) { + if (bits[i] === '..') { + bits.splice(i-1, 1); + } else if (bits[i] === '.') { + bits.splice(i, 1); + } else { + i++; + } + } + return bits.join('/'); + } + + function join(a, b) { + a = a.trim(); + b = b.trim(); + if (/^\//.test(b)) { + return b; + } else { + return a.replace(/\/*$/, '/') + b; + } + } + + function dirname(path) { + var bits = path.split('/'); + bits.pop(); + return bits.join('/'); + } + + /** + * Lookup module names and resolve them by calling the definition function if + * needed. + * @param {string} moduleName A name for the payload to lookup + * @return {object} The module specified by aModuleName or null if not found. + */ + Domain.prototype.lookup = function(moduleName) { + if (/^\./.test(moduleName)) { + moduleName = normalize(join(dirname(this._currentModule), moduleName)); + } + + if (moduleName in this.modules) { + var module = this.modules[moduleName]; + return module; + } + + if (!(moduleName in define.modules)) { + throw new Error("Module not defined: " + moduleName); + } + + var module = define.modules[moduleName]; + + if (typeof module == "function") { + var exports = {}; + var previousModule = this._currentModule; + this._currentModule = moduleName; + module(this.require.bind(this), exports, { id: moduleName, uri: "" }); + this._currentModule = previousModule; + module = exports; + } + + // cache the resulting module object for next time + this.modules[moduleName] = module; + + return module; + }; + +}()); + +define.Domain = Domain; +define.globalDomain = new Domain(); +var require = define.globalDomain.require.bind(define.globalDomain); +/* -*- Mode: js; js-indent-level: 2; -*- */ +/* + * Copyright 2011 Mozilla Foundation and contributors + * Licensed under the New BSD license. See LICENSE or: + * http://opensource.org/licenses/BSD-3-Clause + */ +define('source-map/source-map-generator', ['require', 'exports', 'module' , 'source-map/base64-vlq', 'source-map/util', 'source-map/array-set'], function(require, exports, module) { + + var base64VLQ = require('./base64-vlq'); + var util = require('./util'); + var ArraySet = require('./array-set').ArraySet; + + /** + * An instance of the SourceMapGenerator represents a source map which is + * being built incrementally. To create a new one, you must pass an object + * with the following properties: + * + * - file: The filename of the generated source. + * - sourceRoot: An optional root for all URLs in this source map. + */ + function SourceMapGenerator(aArgs) { + this._file = util.getArg(aArgs, 'file'); + this._sourceRoot = util.getArg(aArgs, 'sourceRoot', null); + this._sources = new ArraySet(); + this._names = new ArraySet(); + this._mappings = []; + this._sourcesContents = null; + } + + SourceMapGenerator.prototype._version = 3; + + /** + * Creates a new SourceMapGenerator based on a SourceMapConsumer + * + * @param aSourceMapConsumer The SourceMap. + */ + SourceMapGenerator.fromSourceMap = + function SourceMapGenerator_fromSourceMap(aSourceMapConsumer) { + var sourceRoot = aSourceMapConsumer.sourceRoot; + var generator = new SourceMapGenerator({ + file: aSourceMapConsumer.file, + sourceRoot: sourceRoot + }); + aSourceMapConsumer.eachMapping(function (mapping) { + var newMapping = { + generated: { + line: mapping.generatedLine, + column: mapping.generatedColumn + } + }; + + if (mapping.source) { + newMapping.source = mapping.source; + if (sourceRoot) { + newMapping.source = util.relative(sourceRoot, newMapping.source); + } + + newMapping.original = { + line: mapping.originalLine, + column: mapping.originalColumn + }; + + if (mapping.name) { + newMapping.name = mapping.name; + } + } + + generator.addMapping(newMapping); + }); + aSourceMapConsumer.sources.forEach(function (sourceFile) { + var content = aSourceMapConsumer.sourceContentFor(sourceFile); + if (content) { + generator.setSourceContent(sourceFile, content); + } + }); + return generator; + }; + + /** + * Add a single mapping from original source line and column to the generated + * source's line and column for this source map being created. The mapping + * object should have the following properties: + * + * - generated: An object with the generated line and column positions. + * - original: An object with the original line and column positions. + * - source: The original source file (relative to the sourceRoot). + * - name: An optional original token name for this mapping. + */ + SourceMapGenerator.prototype.addMapping = + function SourceMapGenerator_addMapping(aArgs) { + var generated = util.getArg(aArgs, 'generated'); + var original = util.getArg(aArgs, 'original', null); + var source = util.getArg(aArgs, 'source', null); + var name = util.getArg(aArgs, 'name', null); + + this._validateMapping(generated, original, source, name); + + if (source && !this._sources.has(source)) { + this._sources.add(source); + } + + if (name && !this._names.has(name)) { + this._names.add(name); + } + + this._mappings.push({ + generatedLine: generated.line, + generatedColumn: generated.column, + originalLine: original != null && original.line, + originalColumn: original != null && original.column, + source: source, + name: name + }); + }; + + /** + * Set the source content for a source file. + */ + SourceMapGenerator.prototype.setSourceContent = + function SourceMapGenerator_setSourceContent(aSourceFile, aSourceContent) { + var source = aSourceFile; + if (this._sourceRoot) { + source = util.relative(this._sourceRoot, source); + } + + if (aSourceContent !== null) { + // Add the source content to the _sourcesContents map. + // Create a new _sourcesContents map if the property is null. + if (!this._sourcesContents) { + this._sourcesContents = {}; + } + this._sourcesContents[util.toSetString(source)] = aSourceContent; + } else { + // Remove the source file from the _sourcesContents map. + // If the _sourcesContents map is empty, set the property to null. + delete this._sourcesContents[util.toSetString(source)]; + if (Object.keys(this._sourcesContents).length === 0) { + this._sourcesContents = null; + } + } + }; + + /** + * Applies the mappings of a sub-source-map for a specific source file to the + * source map being generated. Each mapping to the supplied source file is + * rewritten using the supplied source map. Note: The resolution for the + * resulting mappings is the minimium of this map and the supplied map. + * + * @param aSourceMapConsumer The source map to be applied. + * @param aSourceFile Optional. The filename of the source file. + * If omitted, SourceMapConsumer's file property will be used. + */ + SourceMapGenerator.prototype.applySourceMap = + function SourceMapGenerator_applySourceMap(aSourceMapConsumer, aSourceFile) { + // If aSourceFile is omitted, we will use the file property of the SourceMap + if (!aSourceFile) { + aSourceFile = aSourceMapConsumer.file; + } + var sourceRoot = this._sourceRoot; + // Make "aSourceFile" relative if an absolute Url is passed. + if (sourceRoot) { + aSourceFile = util.relative(sourceRoot, aSourceFile); + } + // Applying the SourceMap can add and remove items from the sources and + // the names array. + var newSources = new ArraySet(); + var newNames = new ArraySet(); + + // Find mappings for the "aSourceFile" + this._mappings.forEach(function (mapping) { + if (mapping.source === aSourceFile && mapping.originalLine) { + // Check if it can be mapped by the source map, then update the mapping. + var original = aSourceMapConsumer.originalPositionFor({ + line: mapping.originalLine, + column: mapping.originalColumn + }); + if (original.source !== null) { + // Copy mapping + if (sourceRoot) { + mapping.source = util.relative(sourceRoot, original.source); + } else { + mapping.source = original.source; + } + mapping.originalLine = original.line; + mapping.originalColumn = original.column; + if (original.name !== null && mapping.name !== null) { + // Only use the identifier name if it's an identifier + // in both SourceMaps + mapping.name = original.name; + } + } + } + + var source = mapping.source; + if (source && !newSources.has(source)) { + newSources.add(source); + } + + var name = mapping.name; + if (name && !newNames.has(name)) { + newNames.add(name); + } + + }, this); + this._sources = newSources; + this._names = newNames; + + // Copy sourcesContents of applied map. + aSourceMapConsumer.sources.forEach(function (sourceFile) { + var content = aSourceMapConsumer.sourceContentFor(sourceFile); + if (content) { + if (sourceRoot) { + sourceFile = util.relative(sourceRoot, sourceFile); + } + this.setSourceContent(sourceFile, content); + } + }, this); + }; + + /** + * A mapping can have one of the three levels of data: + * + * 1. Just the generated position. + * 2. The Generated position, original position, and original source. + * 3. Generated and original position, original source, as well as a name + * token. + * + * To maintain consistency, we validate that any new mapping being added falls + * in to one of these categories. + */ + SourceMapGenerator.prototype._validateMapping = + function SourceMapGenerator_validateMapping(aGenerated, aOriginal, aSource, + aName) { + if (aGenerated && 'line' in aGenerated && 'column' in aGenerated + && aGenerated.line > 0 && aGenerated.column >= 0 + && !aOriginal && !aSource && !aName) { + // Case 1. + return; + } + else if (aGenerated && 'line' in aGenerated && 'column' in aGenerated + && aOriginal && 'line' in aOriginal && 'column' in aOriginal + && aGenerated.line > 0 && aGenerated.column >= 0 + && aOriginal.line > 0 && aOriginal.column >= 0 + && aSource) { + // Cases 2 and 3. + return; + } + else { + throw new Error('Invalid mapping: ' + JSON.stringify({ + generated: aGenerated, + source: aSource, + orginal: aOriginal, + name: aName + })); + } + }; + + /** + * Serialize the accumulated mappings in to the stream of base 64 VLQs + * specified by the source map format. + */ + SourceMapGenerator.prototype._serializeMappings = + function SourceMapGenerator_serializeMappings() { + var previousGeneratedColumn = 0; + var previousGeneratedLine = 1; + var previousOriginalColumn = 0; + var previousOriginalLine = 0; + var previousName = 0; + var previousSource = 0; + var result = ''; + var mapping; + + // The mappings must be guaranteed to be in sorted order before we start + // serializing them or else the generated line numbers (which are defined + // via the ';' separators) will be all messed up. Note: it might be more + // performant to maintain the sorting as we insert them, rather than as we + // serialize them, but the big O is the same either way. + this._mappings.sort(util.compareByGeneratedPositions); + + for (var i = 0, len = this._mappings.length; i < len; i++) { + mapping = this._mappings[i]; + + if (mapping.generatedLine !== previousGeneratedLine) { + previousGeneratedColumn = 0; + while (mapping.generatedLine !== previousGeneratedLine) { + result += ';'; + previousGeneratedLine++; + } + } + else { + if (i > 0) { + if (!util.compareByGeneratedPositions(mapping, this._mappings[i - 1])) { + continue; + } + result += ','; + } + } + + result += base64VLQ.encode(mapping.generatedColumn + - previousGeneratedColumn); + previousGeneratedColumn = mapping.generatedColumn; + + if (mapping.source) { + result += base64VLQ.encode(this._sources.indexOf(mapping.source) + - previousSource); + previousSource = this._sources.indexOf(mapping.source); + + // lines are stored 0-based in SourceMap spec version 3 + result += base64VLQ.encode(mapping.originalLine - 1 + - previousOriginalLine); + previousOriginalLine = mapping.originalLine - 1; + + result += base64VLQ.encode(mapping.originalColumn + - previousOriginalColumn); + previousOriginalColumn = mapping.originalColumn; + + if (mapping.name) { + result += base64VLQ.encode(this._names.indexOf(mapping.name) + - previousName); + previousName = this._names.indexOf(mapping.name); + } + } + } + + return result; + }; + + SourceMapGenerator.prototype._generateSourcesContent = + function SourceMapGenerator_generateSourcesContent(aSources, aSourceRoot) { + return aSources.map(function (source) { + if (!this._sourcesContents) { + return null; + } + if (aSourceRoot) { + source = util.relative(aSourceRoot, source); + } + var key = util.toSetString(source); + return Object.prototype.hasOwnProperty.call(this._sourcesContents, + key) + ? this._sourcesContents[key] + : null; + }, this); + }; + + /** + * Externalize the source map. + */ + SourceMapGenerator.prototype.toJSON = + function SourceMapGenerator_toJSON() { + var map = { + version: this._version, + file: this._file, + sources: this._sources.toArray(), + names: this._names.toArray(), + mappings: this._serializeMappings() + }; + if (this._sourceRoot) { + map.sourceRoot = this._sourceRoot; + } + if (this._sourcesContents) { + map.sourcesContent = this._generateSourcesContent(map.sources, map.sourceRoot); + } + + return map; + }; + + /** + * Render the source map being generated to a string. + */ + SourceMapGenerator.prototype.toString = + function SourceMapGenerator_toString() { + return JSON.stringify(this); + }; + + exports.SourceMapGenerator = SourceMapGenerator; + +}); +/* -*- Mode: js; js-indent-level: 2; -*- */ +/* + * Copyright 2011 Mozilla Foundation and contributors + * Licensed under the New BSD license. See LICENSE or: + * http://opensource.org/licenses/BSD-3-Clause + * + * Based on the Base 64 VLQ implementation in Closure Compiler: + * https://code.google.com/p/closure-compiler/source/browse/trunk/src/com/google/debugging/sourcemap/Base64VLQ.java + * + * Copyright 2011 The Closure Compiler Authors. All rights reserved. + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following + * disclaimer in the documentation and/or other materials provided + * with the distribution. + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +define('source-map/base64-vlq', ['require', 'exports', 'module' , 'source-map/base64'], function(require, exports, module) { + + var base64 = require('./base64'); + + // A single base 64 digit can contain 6 bits of data. For the base 64 variable + // length quantities we use in the source map spec, the first bit is the sign, + // the next four bits are the actual value, and the 6th bit is the + // continuation bit. The continuation bit tells us whether there are more + // digits in this value following this digit. + // + // Continuation + // | Sign + // | | + // V V + // 101011 + + var VLQ_BASE_SHIFT = 5; + + // binary: 100000 + var VLQ_BASE = 1 << VLQ_BASE_SHIFT; + + // binary: 011111 + var VLQ_BASE_MASK = VLQ_BASE - 1; + + // binary: 100000 + var VLQ_CONTINUATION_BIT = VLQ_BASE; + + /** + * Converts from a two-complement value to a value where the sign bit is + * is placed in the least significant bit. For example, as decimals: + * 1 becomes 2 (10 binary), -1 becomes 3 (11 binary) + * 2 becomes 4 (100 binary), -2 becomes 5 (101 binary) + */ + function toVLQSigned(aValue) { + return aValue < 0 + ? ((-aValue) << 1) + 1 + : (aValue << 1) + 0; + } + + /** + * Converts to a two-complement value from a value where the sign bit is + * is placed in the least significant bit. For example, as decimals: + * 2 (10 binary) becomes 1, 3 (11 binary) becomes -1 + * 4 (100 binary) becomes 2, 5 (101 binary) becomes -2 + */ + function fromVLQSigned(aValue) { + var isNegative = (aValue & 1) === 1; + var shifted = aValue >> 1; + return isNegative + ? -shifted + : shifted; + } + + /** + * Returns the base 64 VLQ encoded value. + */ + exports.encode = function base64VLQ_encode(aValue) { + var encoded = ""; + var digit; + + var vlq = toVLQSigned(aValue); + + do { + digit = vlq & VLQ_BASE_MASK; + vlq >>>= VLQ_BASE_SHIFT; + if (vlq > 0) { + // There are still more digits in this value, so we must make sure the + // continuation bit is marked. + digit |= VLQ_CONTINUATION_BIT; + } + encoded += base64.encode(digit); + } while (vlq > 0); + + return encoded; + }; + + /** + * Decodes the next base 64 VLQ value from the given string and returns the + * value and the rest of the string. + */ + exports.decode = function base64VLQ_decode(aStr) { + var i = 0; + var strLen = aStr.length; + var result = 0; + var shift = 0; + var continuation, digit; + + do { + if (i >= strLen) { + throw new Error("Expected more digits in base 64 VLQ value."); + } + digit = base64.decode(aStr.charAt(i++)); + continuation = !!(digit & VLQ_CONTINUATION_BIT); + digit &= VLQ_BASE_MASK; + result = result + (digit << shift); + shift += VLQ_BASE_SHIFT; + } while (continuation); + + return { + value: fromVLQSigned(result), + rest: aStr.slice(i) + }; + }; + +}); +/* -*- Mode: js; js-indent-level: 2; -*- */ +/* + * Copyright 2011 Mozilla Foundation and contributors + * Licensed under the New BSD license. See LICENSE or: + * http://opensource.org/licenses/BSD-3-Clause + */ +define('source-map/base64', ['require', 'exports', 'module' , ], function(require, exports, module) { + + var charToIntMap = {}; + var intToCharMap = {}; + + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + .split('') + .forEach(function (ch, index) { + charToIntMap[ch] = index; + intToCharMap[index] = ch; + }); + + /** + * Encode an integer in the range of 0 to 63 to a single base 64 digit. + */ + exports.encode = function base64_encode(aNumber) { + if (aNumber in intToCharMap) { + return intToCharMap[aNumber]; + } + throw new TypeError("Must be between 0 and 63: " + aNumber); + }; + + /** + * Decode a single base 64 digit to an integer. + */ + exports.decode = function base64_decode(aChar) { + if (aChar in charToIntMap) { + return charToIntMap[aChar]; + } + throw new TypeError("Not a valid base 64 digit: " + aChar); + }; + +}); +/* -*- Mode: js; js-indent-level: 2; -*- */ +/* + * Copyright 2011 Mozilla Foundation and contributors + * Licensed under the New BSD license. See LICENSE or: + * http://opensource.org/licenses/BSD-3-Clause + */ +define('source-map/util', ['require', 'exports', 'module' , ], function(require, exports, module) { + + /** + * This is a helper function for getting values from parameter/options + * objects. + * + * @param args The object we are extracting values from + * @param name The name of the property we are getting. + * @param defaultValue An optional value to return if the property is missing + * from the object. If this is not specified and the property is missing, an + * error will be thrown. + */ + function getArg(aArgs, aName, aDefaultValue) { + if (aName in aArgs) { + return aArgs[aName]; + } else if (arguments.length === 3) { + return aDefaultValue; + } else { + throw new Error('"' + aName + '" is a required argument.'); + } + } + exports.getArg = getArg; + + var urlRegexp = /([\w+\-.]+):\/\/((\w+:\w+)@)?([\w.]+)?(:(\d+))?(\S+)?/; + var dataUrlRegexp = /^data:.+\,.+/; + + function urlParse(aUrl) { + var match = aUrl.match(urlRegexp); + if (!match) { + return null; + } + return { + scheme: match[1], + auth: match[3], + host: match[4], + port: match[6], + path: match[7] + }; + } + exports.urlParse = urlParse; + + function urlGenerate(aParsedUrl) { + var url = aParsedUrl.scheme + "://"; + if (aParsedUrl.auth) { + url += aParsedUrl.auth + "@" + } + if (aParsedUrl.host) { + url += aParsedUrl.host; + } + if (aParsedUrl.port) { + url += ":" + aParsedUrl.port + } + if (aParsedUrl.path) { + url += aParsedUrl.path; + } + return url; + } + exports.urlGenerate = urlGenerate; + + function join(aRoot, aPath) { + var url; + + if (aPath.match(urlRegexp) || aPath.match(dataUrlRegexp)) { + return aPath; + } + + if (aPath.charAt(0) === '/' && (url = urlParse(aRoot))) { + url.path = aPath; + return urlGenerate(url); + } + + return aRoot.replace(/\/$/, '') + '/' + aPath; + } + exports.join = join; + + /** + * Because behavior goes wacky when you set `__proto__` on objects, we + * have to prefix all the strings in our set with an arbitrary character. + * + * See https://github.com/mozilla/source-map/pull/31 and + * https://github.com/mozilla/source-map/issues/30 + * + * @param String aStr + */ + function toSetString(aStr) { + return '$' + aStr; + } + exports.toSetString = toSetString; + + function fromSetString(aStr) { + return aStr.substr(1); + } + exports.fromSetString = fromSetString; + + function relative(aRoot, aPath) { + aRoot = aRoot.replace(/\/$/, ''); + + var url = urlParse(aRoot); + if (aPath.charAt(0) == "/" && url && url.path == "/") { + return aPath.slice(1); + } + + return aPath.indexOf(aRoot + '/') === 0 + ? aPath.substr(aRoot.length + 1) + : aPath; + } + exports.relative = relative; + + function strcmp(aStr1, aStr2) { + var s1 = aStr1 || ""; + var s2 = aStr2 || ""; + return (s1 > s2) - (s1 < s2); + } + + /** + * Comparator between two mappings where the original positions are compared. + * + * Optionally pass in `true` as `onlyCompareGenerated` to consider two + * mappings with the same original source/line/column, but different generated + * line and column the same. Useful when searching for a mapping with a + * stubbed out mapping. + */ + function compareByOriginalPositions(mappingA, mappingB, onlyCompareOriginal) { + var cmp; + + cmp = strcmp(mappingA.source, mappingB.source); + if (cmp) { + return cmp; + } + + cmp = mappingA.originalLine - mappingB.originalLine; + if (cmp) { + return cmp; + } + + cmp = mappingA.originalColumn - mappingB.originalColumn; + if (cmp || onlyCompareOriginal) { + return cmp; + } + + cmp = strcmp(mappingA.name, mappingB.name); + if (cmp) { + return cmp; + } + + cmp = mappingA.generatedLine - mappingB.generatedLine; + if (cmp) { + return cmp; + } + + return mappingA.generatedColumn - mappingB.generatedColumn; + }; + exports.compareByOriginalPositions = compareByOriginalPositions; + + /** + * Comparator between two mappings where the generated positions are + * compared. + * + * Optionally pass in `true` as `onlyCompareGenerated` to consider two + * mappings with the same generated line and column, but different + * source/name/original line and column the same. Useful when searching for a + * mapping with a stubbed out mapping. + */ + function compareByGeneratedPositions(mappingA, mappingB, onlyCompareGenerated) { + var cmp; + + cmp = mappingA.generatedLine - mappingB.generatedLine; + if (cmp) { + return cmp; + } + + cmp = mappingA.generatedColumn - mappingB.generatedColumn; + if (cmp || onlyCompareGenerated) { + return cmp; + } + + cmp = strcmp(mappingA.source, mappingB.source); + if (cmp) { + return cmp; + } + + cmp = mappingA.originalLine - mappingB.originalLine; + if (cmp) { + return cmp; + } + + cmp = mappingA.originalColumn - mappingB.originalColumn; + if (cmp) { + return cmp; + } + + return strcmp(mappingA.name, mappingB.name); + }; + exports.compareByGeneratedPositions = compareByGeneratedPositions; + +}); +/* -*- Mode: js; js-indent-level: 2; -*- */ +/* + * Copyright 2011 Mozilla Foundation and contributors + * Licensed under the New BSD license. See LICENSE or: + * http://opensource.org/licenses/BSD-3-Clause + */ +define('source-map/array-set', ['require', 'exports', 'module' , 'source-map/util'], function(require, exports, module) { + + var util = require('./util'); + + /** + * A data structure which is a combination of an array and a set. Adding a new + * member is O(1), testing for membership is O(1), and finding the index of an + * element is O(1). Removing elements from the set is not supported. Only + * strings are supported for membership. + */ + function ArraySet() { + this._array = []; + this._set = {}; + } + + /** + * Static method for creating ArraySet instances from an existing array. + */ + ArraySet.fromArray = function ArraySet_fromArray(aArray, aAllowDuplicates) { + var set = new ArraySet(); + for (var i = 0, len = aArray.length; i < len; i++) { + set.add(aArray[i], aAllowDuplicates); + } + return set; + }; + + /** + * Add the given string to this set. + * + * @param String aStr + */ + ArraySet.prototype.add = function ArraySet_add(aStr, aAllowDuplicates) { + var isDuplicate = this.has(aStr); + var idx = this._array.length; + if (!isDuplicate || aAllowDuplicates) { + this._array.push(aStr); + } + if (!isDuplicate) { + this._set[util.toSetString(aStr)] = idx; + } + }; + + /** + * Is the given string a member of this set? + * + * @param String aStr + */ + ArraySet.prototype.has = function ArraySet_has(aStr) { + return Object.prototype.hasOwnProperty.call(this._set, + util.toSetString(aStr)); + }; + + /** + * What is the index of the given string in the array? + * + * @param String aStr + */ + ArraySet.prototype.indexOf = function ArraySet_indexOf(aStr) { + if (this.has(aStr)) { + return this._set[util.toSetString(aStr)]; + } + throw new Error('"' + aStr + '" is not in the set.'); + }; + + /** + * What is the element at the given index? + * + * @param Number aIdx + */ + ArraySet.prototype.at = function ArraySet_at(aIdx) { + if (aIdx >= 0 && aIdx < this._array.length) { + return this._array[aIdx]; + } + throw new Error('No element indexed by ' + aIdx); + }; + + /** + * Returns the array representation of this set (which has the proper indices + * indicated by indexOf). Note that this is a copy of the internal array used + * for storing the members so that no one can mess with internal state. + */ + ArraySet.prototype.toArray = function ArraySet_toArray() { + return this._array.slice(); + }; + + exports.ArraySet = ArraySet; + +}); +/* -*- Mode: js; js-indent-level: 2; -*- */ +/* + * Copyright 2011 Mozilla Foundation and contributors + * Licensed under the New BSD license. See LICENSE or: + * http://opensource.org/licenses/BSD-3-Clause + */ +define('source-map/source-map-consumer', ['require', 'exports', 'module' , 'source-map/util', 'source-map/binary-search', 'source-map/array-set', 'source-map/base64-vlq'], function(require, exports, module) { + + var util = require('./util'); + var binarySearch = require('./binary-search'); + var ArraySet = require('./array-set').ArraySet; + var base64VLQ = require('./base64-vlq'); + + /** + * A SourceMapConsumer instance represents a parsed source map which we can + * query for information about the original file positions by giving it a file + * position in the generated source. + * + * The only parameter is the raw source map (either as a JSON string, or + * already parsed to an object). According to the spec, source maps have the + * following attributes: + * + * - version: Which version of the source map spec this map is following. + * - sources: An array of URLs to the original source files. + * - names: An array of identifiers which can be referrenced by individual mappings. + * - sourceRoot: Optional. The URL root from which all sources are relative. + * - sourcesContent: Optional. An array of contents of the original source files. + * - mappings: A string of base64 VLQs which contain the actual mappings. + * - file: The generated file this source map is associated with. + * + * Here is an example source map, taken from the source map spec[0]: + * + * { + * version : 3, + * file: "out.js", + * sourceRoot : "", + * sources: ["foo.js", "bar.js"], + * names: ["src", "maps", "are", "fun"], + * mappings: "AA,AB;;ABCDE;" + * } + * + * [0]: https://docs.google.com/document/d/1U1RGAehQwRypUTovF1KRlpiOFze0b-_2gc6fAH0KY0k/edit?pli=1# + */ + function SourceMapConsumer(aSourceMap) { + var sourceMap = aSourceMap; + if (typeof aSourceMap === 'string') { + sourceMap = JSON.parse(aSourceMap.replace(/^\)\]\}'/, '')); + } + + var version = util.getArg(sourceMap, 'version'); + var sources = util.getArg(sourceMap, 'sources'); + // Sass 3.3 leaves out the 'names' array, so we deviate from the spec (which + // requires the array) to play nice here. + var names = util.getArg(sourceMap, 'names', []); + var sourceRoot = util.getArg(sourceMap, 'sourceRoot', null); + var sourcesContent = util.getArg(sourceMap, 'sourcesContent', null); + var mappings = util.getArg(sourceMap, 'mappings'); + var file = util.getArg(sourceMap, 'file', null); + + // Once again, Sass deviates from the spec and supplies the version as a + // string rather than a number, so we use loose equality checking here. + if (version != this._version) { + throw new Error('Unsupported version: ' + version); + } + + // Pass `true` below to allow duplicate names and sources. While source maps + // are intended to be compressed and deduplicated, the TypeScript compiler + // sometimes generates source maps with duplicates in them. See Github issue + // #72 and bugzil.la/889492. + this._names = ArraySet.fromArray(names, true); + this._sources = ArraySet.fromArray(sources, true); + + this.sourceRoot = sourceRoot; + this.sourcesContent = sourcesContent; + this._mappings = mappings; + this.file = file; + } + + /** + * Create a SourceMapConsumer from a SourceMapGenerator. + * + * @param SourceMapGenerator aSourceMap + * The source map that will be consumed. + * @returns SourceMapConsumer + */ + SourceMapConsumer.fromSourceMap = + function SourceMapConsumer_fromSourceMap(aSourceMap) { + var smc = Object.create(SourceMapConsumer.prototype); + + smc._names = ArraySet.fromArray(aSourceMap._names.toArray(), true); + smc._sources = ArraySet.fromArray(aSourceMap._sources.toArray(), true); + smc.sourceRoot = aSourceMap._sourceRoot; + smc.sourcesContent = aSourceMap._generateSourcesContent(smc._sources.toArray(), + smc.sourceRoot); + smc.file = aSourceMap._file; + + smc.__generatedMappings = aSourceMap._mappings.slice() + .sort(util.compareByGeneratedPositions); + smc.__originalMappings = aSourceMap._mappings.slice() + .sort(util.compareByOriginalPositions); + + return smc; + }; + + /** + * The version of the source mapping spec that we are consuming. + */ + SourceMapConsumer.prototype._version = 3; + + /** + * The list of original sources. + */ + Object.defineProperty(SourceMapConsumer.prototype, 'sources', { + get: function () { + return this._sources.toArray().map(function (s) { + return this.sourceRoot ? util.join(this.sourceRoot, s) : s; + }, this); + } + }); + + // `__generatedMappings` and `__originalMappings` are arrays that hold the + // parsed mapping coordinates from the source map's "mappings" attribute. They + // are lazily instantiated, accessed via the `_generatedMappings` and + // `_originalMappings` getters respectively, and we only parse the mappings + // and create these arrays once queried for a source location. We jump through + // these hoops because there can be many thousands of mappings, and parsing + // them is expensive, so we only want to do it if we must. + // + // Each object in the arrays is of the form: + // + // { + // generatedLine: The line number in the generated code, + // generatedColumn: The column number in the generated code, + // source: The path to the original source file that generated this + // chunk of code, + // originalLine: The line number in the original source that + // corresponds to this chunk of generated code, + // originalColumn: The column number in the original source that + // corresponds to this chunk of generated code, + // name: The name of the original symbol which generated this chunk of + // code. + // } + // + // All properties except for `generatedLine` and `generatedColumn` can be + // `null`. + // + // `_generatedMappings` is ordered by the generated positions. + // + // `_originalMappings` is ordered by the original positions. + + SourceMapConsumer.prototype.__generatedMappings = null; + Object.defineProperty(SourceMapConsumer.prototype, '_generatedMappings', { + get: function () { + if (!this.__generatedMappings) { + this.__generatedMappings = []; + this.__originalMappings = []; + this._parseMappings(this._mappings, this.sourceRoot); + } + + return this.__generatedMappings; + } + }); + + SourceMapConsumer.prototype.__originalMappings = null; + Object.defineProperty(SourceMapConsumer.prototype, '_originalMappings', { + get: function () { + if (!this.__originalMappings) { + this.__generatedMappings = []; + this.__originalMappings = []; + this._parseMappings(this._mappings, this.sourceRoot); + } + + return this.__originalMappings; + } + }); + + /** + * Parse the mappings in a string in to a data structure which we can easily + * query (the ordered arrays in the `this.__generatedMappings` and + * `this.__originalMappings` properties). + */ + SourceMapConsumer.prototype._parseMappings = + function SourceMapConsumer_parseMappings(aStr, aSourceRoot) { + var generatedLine = 1; + var previousGeneratedColumn = 0; + var previousOriginalLine = 0; + var previousOriginalColumn = 0; + var previousSource = 0; + var previousName = 0; + var mappingSeparator = /^[,;]/; + var str = aStr; + var mapping; + var temp; + + while (str.length > 0) { + if (str.charAt(0) === ';') { + generatedLine++; + str = str.slice(1); + previousGeneratedColumn = 0; + } + else if (str.charAt(0) === ',') { + str = str.slice(1); + } + else { + mapping = {}; + mapping.generatedLine = generatedLine; + + // Generated column. + temp = base64VLQ.decode(str); + mapping.generatedColumn = previousGeneratedColumn + temp.value; + previousGeneratedColumn = mapping.generatedColumn; + str = temp.rest; + + if (str.length > 0 && !mappingSeparator.test(str.charAt(0))) { + // Original source. + temp = base64VLQ.decode(str); + mapping.source = this._sources.at(previousSource + temp.value); + previousSource += temp.value; + str = temp.rest; + if (str.length === 0 || mappingSeparator.test(str.charAt(0))) { + throw new Error('Found a source, but no line and column'); + } + + // Original line. + temp = base64VLQ.decode(str); + mapping.originalLine = previousOriginalLine + temp.value; + previousOriginalLine = mapping.originalLine; + // Lines are stored 0-based + mapping.originalLine += 1; + str = temp.rest; + if (str.length === 0 || mappingSeparator.test(str.charAt(0))) { + throw new Error('Found a source and line, but no column'); + } + + // Original column. + temp = base64VLQ.decode(str); + mapping.originalColumn = previousOriginalColumn + temp.value; + previousOriginalColumn = mapping.originalColumn; + str = temp.rest; + + if (str.length > 0 && !mappingSeparator.test(str.charAt(0))) { + // Original name. + temp = base64VLQ.decode(str); + mapping.name = this._names.at(previousName + temp.value); + previousName += temp.value; + str = temp.rest; + } + } + + this.__generatedMappings.push(mapping); + if (typeof mapping.originalLine === 'number') { + this.__originalMappings.push(mapping); + } + } + } + + this.__originalMappings.sort(util.compareByOriginalPositions); + }; + + /** + * Find the mapping that best matches the hypothetical "needle" mapping that + * we are searching for in the given "haystack" of mappings. + */ + SourceMapConsumer.prototype._findMapping = + function SourceMapConsumer_findMapping(aNeedle, aMappings, aLineName, + aColumnName, aComparator) { + // To return the position we are searching for, we must first find the + // mapping for the given position and then return the opposite position it + // points to. Because the mappings are sorted, we can use binary search to + // find the best mapping. + + if (aNeedle[aLineName] <= 0) { + throw new TypeError('Line must be greater than or equal to 1, got ' + + aNeedle[aLineName]); + } + if (aNeedle[aColumnName] < 0) { + throw new TypeError('Column must be greater than or equal to 0, got ' + + aNeedle[aColumnName]); + } + + return binarySearch.search(aNeedle, aMappings, aComparator); + }; + + /** + * Returns the original source, line, and column information for the generated + * source's line and column positions provided. The only argument is an object + * with the following properties: + * + * - line: The line number in the generated source. + * - column: The column number in the generated source. + * + * and an object is returned with the following properties: + * + * - source: The original source file, or null. + * - line: The line number in the original source, or null. + * - column: The column number in the original source, or null. + * - name: The original identifier, or null. + */ + SourceMapConsumer.prototype.originalPositionFor = + function SourceMapConsumer_originalPositionFor(aArgs) { + var needle = { + generatedLine: util.getArg(aArgs, 'line'), + generatedColumn: util.getArg(aArgs, 'column') + }; + + var mapping = this._findMapping(needle, + this._generatedMappings, + "generatedLine", + "generatedColumn", + util.compareByGeneratedPositions); + + if (mapping) { + var source = util.getArg(mapping, 'source', null); + if (source && this.sourceRoot) { + source = util.join(this.sourceRoot, source); + } + return { + source: source, + line: util.getArg(mapping, 'originalLine', null), + column: util.getArg(mapping, 'originalColumn', null), + name: util.getArg(mapping, 'name', null) + }; + } + + return { + source: null, + line: null, + column: null, + name: null + }; + }; + + /** + * Returns the original source content. The only argument is the url of the + * original source file. Returns null if no original source content is + * availible. + */ + SourceMapConsumer.prototype.sourceContentFor = + function SourceMapConsumer_sourceContentFor(aSource) { + if (!this.sourcesContent) { + return null; + } + + if (this.sourceRoot) { + aSource = util.relative(this.sourceRoot, aSource); + } + + if (this._sources.has(aSource)) { + return this.sourcesContent[this._sources.indexOf(aSource)]; + } + + var url; + if (this.sourceRoot + && (url = util.urlParse(this.sourceRoot))) { + // XXX: file:// URIs and absolute paths lead to unexpected behavior for + // many users. We can help them out when they expect file:// URIs to + // behave like it would if they were running a local HTTP server. See + // https://bugzilla.mozilla.org/show_bug.cgi?id=885597. + var fileUriAbsPath = aSource.replace(/^file:\/\//, ""); + if (url.scheme == "file" + && this._sources.has(fileUriAbsPath)) { + return this.sourcesContent[this._sources.indexOf(fileUriAbsPath)] + } + + if ((!url.path || url.path == "/") + && this._sources.has("/" + aSource)) { + return this.sourcesContent[this._sources.indexOf("/" + aSource)]; + } + } + + throw new Error('"' + aSource + '" is not in the SourceMap.'); + }; + + /** + * Returns the generated line and column information for the original source, + * line, and column positions provided. The only argument is an object with + * the following properties: + * + * - source: The filename of the original source. + * - line: The line number in the original source. + * - column: The column number in the original source. + * + * and an object is returned with the following properties: + * + * - line: The line number in the generated source, or null. + * - column: The column number in the generated source, or null. + */ + SourceMapConsumer.prototype.generatedPositionFor = + function SourceMapConsumer_generatedPositionFor(aArgs) { + var needle = { + source: util.getArg(aArgs, 'source'), + originalLine: util.getArg(aArgs, 'line'), + originalColumn: util.getArg(aArgs, 'column') + }; + + if (this.sourceRoot) { + needle.source = util.relative(this.sourceRoot, needle.source); + } + + var mapping = this._findMapping(needle, + this._originalMappings, + "originalLine", + "originalColumn", + util.compareByOriginalPositions); + + if (mapping) { + return { + line: util.getArg(mapping, 'generatedLine', null), + column: util.getArg(mapping, 'generatedColumn', null) + }; + } + + return { + line: null, + column: null + }; + }; + + SourceMapConsumer.GENERATED_ORDER = 1; + SourceMapConsumer.ORIGINAL_ORDER = 2; + + /** + * Iterate over each mapping between an original source/line/column and a + * generated line/column in this source map. + * + * @param Function aCallback + * The function that is called with each mapping. + * @param Object aContext + * Optional. If specified, this object will be the value of `this` every + * time that `aCallback` is called. + * @param aOrder + * Either `SourceMapConsumer.GENERATED_ORDER` or + * `SourceMapConsumer.ORIGINAL_ORDER`. Specifies whether you want to + * iterate over the mappings sorted by the generated file's line/column + * order or the original's source/line/column order, respectively. Defaults to + * `SourceMapConsumer.GENERATED_ORDER`. + */ + SourceMapConsumer.prototype.eachMapping = + function SourceMapConsumer_eachMapping(aCallback, aContext, aOrder) { + var context = aContext || null; + var order = aOrder || SourceMapConsumer.GENERATED_ORDER; + + var mappings; + switch (order) { + case SourceMapConsumer.GENERATED_ORDER: + mappings = this._generatedMappings; + break; + case SourceMapConsumer.ORIGINAL_ORDER: + mappings = this._originalMappings; + break; + default: + throw new Error("Unknown order of iteration."); + } + + var sourceRoot = this.sourceRoot; + mappings.map(function (mapping) { + var source = mapping.source; + if (source && sourceRoot) { + source = util.join(sourceRoot, source); + } + return { + source: source, + generatedLine: mapping.generatedLine, + generatedColumn: mapping.generatedColumn, + originalLine: mapping.originalLine, + originalColumn: mapping.originalColumn, + name: mapping.name + }; + }).forEach(aCallback, context); + }; + + exports.SourceMapConsumer = SourceMapConsumer; + +}); +/* -*- Mode: js; js-indent-level: 2; -*- */ +/* + * Copyright 2011 Mozilla Foundation and contributors + * Licensed under the New BSD license. See LICENSE or: + * http://opensource.org/licenses/BSD-3-Clause + */ +define('source-map/binary-search', ['require', 'exports', 'module' , ], function(require, exports, module) { + + /** + * Recursive implementation of binary search. + * + * @param aLow Indices here and lower do not contain the needle. + * @param aHigh Indices here and higher do not contain the needle. + * @param aNeedle The element being searched for. + * @param aHaystack The non-empty array being searched. + * @param aCompare Function which takes two elements and returns -1, 0, or 1. + */ + function recursiveSearch(aLow, aHigh, aNeedle, aHaystack, aCompare) { + // This function terminates when one of the following is true: + // + // 1. We find the exact element we are looking for. + // + // 2. We did not find the exact element, but we can return the next + // closest element that is less than that element. + // + // 3. We did not find the exact element, and there is no next-closest + // element which is less than the one we are searching for, so we + // return null. + var mid = Math.floor((aHigh - aLow) / 2) + aLow; + var cmp = aCompare(aNeedle, aHaystack[mid], true); + if (cmp === 0) { + // Found the element we are looking for. + return aHaystack[mid]; + } + else if (cmp > 0) { + // aHaystack[mid] is greater than our needle. + if (aHigh - mid > 1) { + // The element is in the upper half. + return recursiveSearch(mid, aHigh, aNeedle, aHaystack, aCompare); + } + // We did not find an exact match, return the next closest one + // (termination case 2). + return aHaystack[mid]; + } + else { + // aHaystack[mid] is less than our needle. + if (mid - aLow > 1) { + // The element is in the lower half. + return recursiveSearch(aLow, mid, aNeedle, aHaystack, aCompare); + } + // The exact needle element was not found in this haystack. Determine if + // we are in termination case (2) or (3) and return the appropriate thing. + return aLow < 0 + ? null + : aHaystack[aLow]; + } + } + + /** + * This is an implementation of binary search which will always try and return + * the next lowest value checked if there is no exact hit. This is because + * mappings between original and generated line/col pairs are single points, + * and there is an implicit region between each of them, so a miss just means + * that you aren't on the very start of a region. + * + * @param aNeedle The element you are looking for. + * @param aHaystack The array that is being searched. + * @param aCompare A function which takes the needle and an element in the + * array and returns -1, 0, or 1 depending on whether the needle is less + * than, equal to, or greater than the element, respectively. + */ + exports.search = function search(aNeedle, aHaystack, aCompare) { + return aHaystack.length > 0 + ? recursiveSearch(-1, aHaystack.length, aNeedle, aHaystack, aCompare) + : null; + }; + +}); +/* -*- Mode: js; js-indent-level: 2; -*- */ +/* + * Copyright 2011 Mozilla Foundation and contributors + * Licensed under the New BSD license. See LICENSE or: + * http://opensource.org/licenses/BSD-3-Clause + */ +define('source-map/source-node', ['require', 'exports', 'module' , 'source-map/source-map-generator', 'source-map/util'], function(require, exports, module) { + + var SourceMapGenerator = require('./source-map-generator').SourceMapGenerator; + var util = require('./util'); + + /** + * SourceNodes provide a way to abstract over interpolating/concatenating + * snippets of generated JavaScript source code while maintaining the line and + * column information associated with the original source code. + * + * @param aLine The original line number. + * @param aColumn The original column number. + * @param aSource The original source's filename. + * @param aChunks Optional. An array of strings which are snippets of + * generated JS, or other SourceNodes. + * @param aName The original identifier. + */ + function SourceNode(aLine, aColumn, aSource, aChunks, aName) { + this.children = []; + this.sourceContents = {}; + this.line = aLine === undefined ? null : aLine; + this.column = aColumn === undefined ? null : aColumn; + this.source = aSource === undefined ? null : aSource; + this.name = aName === undefined ? null : aName; + if (aChunks != null) this.add(aChunks); + } + + /** + * Creates a SourceNode from generated code and a SourceMapConsumer. + * + * @param aGeneratedCode The generated code + * @param aSourceMapConsumer The SourceMap for the generated code + */ + SourceNode.fromStringWithSourceMap = + function SourceNode_fromStringWithSourceMap(aGeneratedCode, aSourceMapConsumer) { + // The SourceNode we want to fill with the generated code + // and the SourceMap + var node = new SourceNode(); + + // The generated code + // Processed fragments are removed from this array. + var remainingLines = aGeneratedCode.split('\n'); + + // We need to remember the position of "remainingLines" + var lastGeneratedLine = 1, lastGeneratedColumn = 0; + + // The generate SourceNodes we need a code range. + // To extract it current and last mapping is used. + // Here we store the last mapping. + var lastMapping = null; + + aSourceMapConsumer.eachMapping(function (mapping) { + if (lastMapping === null) { + // We add the generated code until the first mapping + // to the SourceNode without any mapping. + // Each line is added as separate string. + while (lastGeneratedLine < mapping.generatedLine) { + node.add(remainingLines.shift() + "\n"); + lastGeneratedLine++; + } + if (lastGeneratedColumn < mapping.generatedColumn) { + var nextLine = remainingLines[0]; + node.add(nextLine.substr(0, mapping.generatedColumn)); + remainingLines[0] = nextLine.substr(mapping.generatedColumn); + lastGeneratedColumn = mapping.generatedColumn; + } + } else { + // We add the code from "lastMapping" to "mapping": + // First check if there is a new line in between. + if (lastGeneratedLine < mapping.generatedLine) { + var code = ""; + // Associate full lines with "lastMapping" + do { + code += remainingLines.shift() + "\n"; + lastGeneratedLine++; + lastGeneratedColumn = 0; + } while (lastGeneratedLine < mapping.generatedLine); + // When we reached the correct line, we add code until we + // reach the correct column too. + if (lastGeneratedColumn < mapping.generatedColumn) { + var nextLine = remainingLines[0]; + code += nextLine.substr(0, mapping.generatedColumn); + remainingLines[0] = nextLine.substr(mapping.generatedColumn); + lastGeneratedColumn = mapping.generatedColumn; + } + // Create the SourceNode. + addMappingWithCode(lastMapping, code); + } else { + // There is no new line in between. + // Associate the code between "lastGeneratedColumn" and + // "mapping.generatedColumn" with "lastMapping" + var nextLine = remainingLines[0]; + var code = nextLine.substr(0, mapping.generatedColumn - + lastGeneratedColumn); + remainingLines[0] = nextLine.substr(mapping.generatedColumn - + lastGeneratedColumn); + lastGeneratedColumn = mapping.generatedColumn; + addMappingWithCode(lastMapping, code); + } + } + lastMapping = mapping; + }, this); + // We have processed all mappings. + // Associate the remaining code in the current line with "lastMapping" + // and add the remaining lines without any mapping + addMappingWithCode(lastMapping, remainingLines.join("\n")); + + // Copy sourcesContent into SourceNode + aSourceMapConsumer.sources.forEach(function (sourceFile) { + var content = aSourceMapConsumer.sourceContentFor(sourceFile); + if (content) { + node.setSourceContent(sourceFile, content); + } + }); + + return node; + + function addMappingWithCode(mapping, code) { + if (mapping === null || mapping.source === undefined) { + node.add(code); + } else { + node.add(new SourceNode(mapping.originalLine, + mapping.originalColumn, + mapping.source, + code, + mapping.name)); + } + } + }; + + /** + * Add a chunk of generated JS to this source node. + * + * @param aChunk A string snippet of generated JS code, another instance of + * SourceNode, or an array where each member is one of those things. + */ + SourceNode.prototype.add = function SourceNode_add(aChunk) { + if (Array.isArray(aChunk)) { + aChunk.forEach(function (chunk) { + this.add(chunk); + }, this); + } + else if (aChunk instanceof SourceNode || typeof aChunk === "string") { + if (aChunk) { + this.children.push(aChunk); + } + } + else { + throw new TypeError( + "Expected a SourceNode, string, or an array of SourceNodes and strings. Got " + aChunk + ); + } + return this; + }; + + /** + * Add a chunk of generated JS to the beginning of this source node. + * + * @param aChunk A string snippet of generated JS code, another instance of + * SourceNode, or an array where each member is one of those things. + */ + SourceNode.prototype.prepend = function SourceNode_prepend(aChunk) { + if (Array.isArray(aChunk)) { + for (var i = aChunk.length-1; i >= 0; i--) { + this.prepend(aChunk[i]); + } + } + else if (aChunk instanceof SourceNode || typeof aChunk === "string") { + this.children.unshift(aChunk); + } + else { + throw new TypeError( + "Expected a SourceNode, string, or an array of SourceNodes and strings. Got " + aChunk + ); + } + return this; + }; + + /** + * Walk over the tree of JS snippets in this node and its children. The + * walking function is called once for each snippet of JS and is passed that + * snippet and the its original associated source's line/column location. + * + * @param aFn The traversal function. + */ + SourceNode.prototype.walk = function SourceNode_walk(aFn) { + var chunk; + for (var i = 0, len = this.children.length; i < len; i++) { + chunk = this.children[i]; + if (chunk instanceof SourceNode) { + chunk.walk(aFn); + } + else { + if (chunk !== '') { + aFn(chunk, { source: this.source, + line: this.line, + column: this.column, + name: this.name }); + } + } + } + }; + + /** + * Like `String.prototype.join` except for SourceNodes. Inserts `aStr` between + * each of `this.children`. + * + * @param aSep The separator. + */ + SourceNode.prototype.join = function SourceNode_join(aSep) { + var newChildren; + var i; + var len = this.children.length; + if (len > 0) { + newChildren = []; + for (i = 0; i < len-1; i++) { + newChildren.push(this.children[i]); + newChildren.push(aSep); + } + newChildren.push(this.children[i]); + this.children = newChildren; + } + return this; + }; + + /** + * Call String.prototype.replace on the very right-most source snippet. Useful + * for trimming whitespace from the end of a source node, etc. + * + * @param aPattern The pattern to replace. + * @param aReplacement The thing to replace the pattern with. + */ + SourceNode.prototype.replaceRight = function SourceNode_replaceRight(aPattern, aReplacement) { + var lastChild = this.children[this.children.length - 1]; + if (lastChild instanceof SourceNode) { + lastChild.replaceRight(aPattern, aReplacement); + } + else if (typeof lastChild === 'string') { + this.children[this.children.length - 1] = lastChild.replace(aPattern, aReplacement); + } + else { + this.children.push(''.replace(aPattern, aReplacement)); + } + return this; + }; + + /** + * Set the source content for a source file. This will be added to the SourceMapGenerator + * in the sourcesContent field. + * + * @param aSourceFile The filename of the source file + * @param aSourceContent The content of the source file + */ + SourceNode.prototype.setSourceContent = + function SourceNode_setSourceContent(aSourceFile, aSourceContent) { + this.sourceContents[util.toSetString(aSourceFile)] = aSourceContent; + }; + + /** + * Walk over the tree of SourceNodes. The walking function is called for each + * source file content and is passed the filename and source content. + * + * @param aFn The traversal function. + */ + SourceNode.prototype.walkSourceContents = + function SourceNode_walkSourceContents(aFn) { + for (var i = 0, len = this.children.length; i < len; i++) { + if (this.children[i] instanceof SourceNode) { + this.children[i].walkSourceContents(aFn); + } + } + + var sources = Object.keys(this.sourceContents); + for (var i = 0, len = sources.length; i < len; i++) { + aFn(util.fromSetString(sources[i]), this.sourceContents[sources[i]]); + } + }; + + /** + * Return the string representation of this source node. Walks over the tree + * and concatenates all the various snippets together to one string. + */ + SourceNode.prototype.toString = function SourceNode_toString() { + var str = ""; + this.walk(function (chunk) { + str += chunk; + }); + return str; + }; + + /** + * Returns the string representation of this source node along with a source + * map. + */ + SourceNode.prototype.toStringWithSourceMap = function SourceNode_toStringWithSourceMap(aArgs) { + var generated = { + code: "", + line: 1, + column: 0 + }; + var map = new SourceMapGenerator(aArgs); + var sourceMappingActive = false; + var lastOriginalSource = null; + var lastOriginalLine = null; + var lastOriginalColumn = null; + var lastOriginalName = null; + this.walk(function (chunk, original) { + generated.code += chunk; + if (original.source !== null + && original.line !== null + && original.column !== null) { + if(lastOriginalSource !== original.source + || lastOriginalLine !== original.line + || lastOriginalColumn !== original.column + || lastOriginalName !== original.name) { + map.addMapping({ + source: original.source, + original: { + line: original.line, + column: original.column + }, + generated: { + line: generated.line, + column: generated.column + }, + name: original.name + }); + } + lastOriginalSource = original.source; + lastOriginalLine = original.line; + lastOriginalColumn = original.column; + lastOriginalName = original.name; + sourceMappingActive = true; + } else if (sourceMappingActive) { + map.addMapping({ + generated: { + line: generated.line, + column: generated.column + } + }); + lastOriginalSource = null; + sourceMappingActive = false; + } + chunk.split('').forEach(function (ch) { + if (ch === '\n') { + generated.line++; + generated.column = 0; + } else { + generated.column++; + } + }); + }); + this.walkSourceContents(function (sourceFile, sourceContent) { + map.setSourceContent(sourceFile, sourceContent); + }); + + return { code: generated.code, map: map }; + }; + + exports.SourceNode = SourceNode; + +}); +/* -*- Mode: js; js-indent-level: 2; -*- */ +/////////////////////////////////////////////////////////////////////////////// + +this.sourceMap = { + SourceMapConsumer: require('source-map/source-map-consumer').SourceMapConsumer, + SourceMapGenerator: require('source-map/source-map-generator').SourceMapGenerator, + SourceNode: require('source-map/source-node').SourceNode +}; + +} diff --git a/Libraries/JavaScriptAppEngine/Initialization/errorToString.js b/Libraries/JavaScriptAppEngine/Initialization/errorToString.js new file mode 100644 index 0000000000..652d6c2aff --- /dev/null +++ b/Libraries/JavaScriptAppEngine/Initialization/errorToString.js @@ -0,0 +1,54 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule errorToString + */ +'use strict'; + +var Platform = require('Platform'); + +var stacktraceParser = require('stacktrace-parser'); + +function stackFrameToString(stackFrame) { + var fileNameParts = stackFrame.file.split('/'); + var fileName = fileNameParts[fileNameParts.length - 1]; + + return stackFrame.methodName + '\n in ' + fileName + ':' + stackFrame.lineNumber + '\n'; +} + +function resolveSourceMaps(sourceMapInstance, stackFrame) { + try { + var orig = sourceMapInstance.originalPositionFor({ + line: stackFrame.lineNumber, + column: stackFrame.column, + }); + if (orig) { + stackFrame.file = orig.source; + stackFrame.lineNumber = orig.line; + stackFrame.column = orig.column; + } + } catch (innerEx) { + } +} + +function errorToString(e, sourceMapInstance) { + var stack = stacktraceParser.parse(e.stack); + + var framesToPop = e.framesToPop || 0; + while (framesToPop--) { + stack.shift(); + } + + if (sourceMapInstance) { + stack.forEach(resolveSourceMaps.bind(null, sourceMapInstance)); + } + + // HACK(frantic) Android currently expects stack trace to be a string #5920439 + if (Platform.OS === 'android') { + return stack.map(stackFrameToString).join('\n'); + } else { + return stack; + } +} + +module.exports = errorToString; diff --git a/Libraries/JavaScriptAppEngine/Initialization/loadSourceMap.js b/Libraries/JavaScriptAppEngine/Initialization/loadSourceMap.js new file mode 100644 index 0000000000..076f9fa5c1 --- /dev/null +++ b/Libraries/JavaScriptAppEngine/Initialization/loadSourceMap.js @@ -0,0 +1,24 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule loadSourceMap + */ + +'use strict'; + +var SourceMapConsumer = require('SourceMap').SourceMapConsumer; + +var sourceMapInstance; + +function loadSourceMap() { + if (sourceMapInstance !== undefined) { + return sourceMapInstance; + } + if (!global.RAW_SOURCE_MAP) { + return null; + } + sourceMapInstance = new SourceMapConsumer(global.RAW_SOURCE_MAP); + return sourceMapInstance; +} + +module.exports = loadSourceMap; diff --git a/Libraries/JavaScriptAppEngine/System/JSTimers/JSTimers.js b/Libraries/JavaScriptAppEngine/System/JSTimers/JSTimers.js new file mode 100644 index 0000000000..13297ef009 --- /dev/null +++ b/Libraries/JavaScriptAppEngine/System/JSTimers/JSTimers.js @@ -0,0 +1,135 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule JSTimers + */ +'use strict'; + +// Note that the module JSTimers is split into two in order to solve a cycle +// in dependencies. NativeModules > BatchedBridge > MessageQueue > JSTimersExecution +var RKTiming = require('NativeModules').RKTiming; +var JSTimersExecution = require('JSTimersExecution'); + +/** + * JS implementation of timer functions. Must be completely driven by an + * external clock signal, all that's stored here is timerID, timer type, and + * callback. + */ +var JSTimers = { + Types: JSTimersExecution.Types, + + /** + * Returns a free index if one is available, and the next consecutive index + * otherwise. + */ + _getFreeIndex: function() { + var freeIndex = JSTimersExecution.timerIDs.indexOf(null); + if (freeIndex === -1) { + freeIndex = JSTimersExecution.timerIDs.length; + } + return freeIndex; + }, + + /** + * @param {function} func Callback to be invoked after `duration` ms. + * @param {number} duration Number of milliseconds. + */ + setTimeout: function(func, duration, ...args) { + var newID = JSTimersExecution.GUID++; + var freeIndex = JSTimers._getFreeIndex(); + JSTimersExecution.timerIDs[freeIndex] = newID; + JSTimersExecution.callbacks[freeIndex] = func; + JSTimersExecution.callbacks[freeIndex] = function() { + return func.apply(undefined, args); + }; + JSTimersExecution.types[freeIndex] = JSTimersExecution.Type.setTimeout; + RKTiming.createTimer(newID, duration, Date.now(), /** recurring */ false); + return newID; + }, + + /** + * @param {function} func Callback to be invoked every `duration` ms. + * @param {number} duration Number of milliseconds. + */ + setInterval: function(func, duration, ...args) { + var newID = JSTimersExecution.GUID++; + var freeIndex = JSTimers._getFreeIndex(); + JSTimersExecution.timerIDs[freeIndex] = newID; + JSTimersExecution.callbacks[freeIndex] = func; + JSTimersExecution.callbacks[freeIndex] = function() { + return func.apply(undefined, args); + }; + JSTimersExecution.types[freeIndex] = JSTimersExecution.Type.setInterval; + RKTiming.createTimer(newID, duration, Date.now(), /** recurring */ true); + return newID; + }, + + /** + * @param {function} func Callback to be invoked before the end of the + * current JavaScript execution loop. + */ + setImmediate: function(func, ...args) { + var newID = JSTimersExecution.GUID++; + var freeIndex = JSTimers._getFreeIndex(); + JSTimersExecution.timerIDs[freeIndex] = newID; + JSTimersExecution.callbacks[freeIndex] = func; + JSTimersExecution.callbacks[freeIndex] = function() { + return func.apply(undefined, args); + }; + JSTimersExecution.types[freeIndex] = JSTimersExecution.Type.setImmediate; + JSTimersExecution.immediates.push(newID); + return newID; + }, + + /** + * @param {function} func Callback to be invoked every frame. + */ + requestAnimationFrame: function(func) { + var newID = JSTimersExecution.GUID++; + var freeIndex = JSTimers._getFreeIndex(); + JSTimersExecution.timerIDs[freeIndex] = newID; + JSTimersExecution.callbacks[freeIndex] = func; + JSTimersExecution.types[freeIndex] = JSTimersExecution.Type.requestAnimationFrame; + RKTiming.createTimer(newID, 0, Date.now(), /** recurring */ false); + return newID; + }, + + clearTimeout: function(timerID) { + JSTimers._clearTimerID(timerID); + }, + + clearInterval: function(timerID) { + JSTimers._clearTimerID(timerID); + }, + + clearImmediate: function(timerID) { + JSTimers._clearTimerID(timerID); + JSTimersExecution.immediates.splice( + JSTimersExecution.immediates.indexOf(timerID), + 1 + ); + }, + + cancelAnimationFrame: function(timerID) { + JSTimers._clearTimerID(timerID); + }, + + _clearTimerID: function(timerID) { + // JSTimersExecution.timerIDs contains nulls after timers have been removed; + // ignore nulls upfront so indexOf doesn't find them + if (timerID == null) { + return; + } + + var index = JSTimersExecution.timerIDs.indexOf(timerID); + // See corresponding comment in `callTimers` for reasoning behind this + if (index !== -1) { + JSTimersExecution._clearIndex(index); + if (JSTimersExecution.types[index] !== JSTimersExecution.Type.setImmediate) { + RKTiming.deleteTimer(timerID); + } + } + }, +}; + +module.exports = JSTimers; diff --git a/Libraries/JavaScriptAppEngine/System/JSTimers/JSTimersExecution.js b/Libraries/JavaScriptAppEngine/System/JSTimers/JSTimersExecution.js new file mode 100644 index 0000000000..ee86472b3f --- /dev/null +++ b/Libraries/JavaScriptAppEngine/System/JSTimers/JSTimersExecution.js @@ -0,0 +1,128 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule JSTimersExecution + */ +'use strict'; + +var invariant = require('invariant'); +var keyMirror = require('keyMirror'); +var performanceNow = require('performanceNow'); +var warning = require('warning'); + +/** + * JS implementation of timer functions. Must be completely driven by an + * external clock signal, all that's stored here is timerID, timer type, and + * callback. + */ +var JSTimersExecution = { + GUID: 1, + Type: keyMirror({ + setTimeout: null, + setInterval: null, + requestAnimationFrame: null, + setImmediate: null, + }), + + // Parallel arrays: + callbacks: [], + types: [], + timerIDs: [], + immediates: [], + + /** + * Calls the callback associated with the ID. Also unregister that callback + * if it was a one time timer (setTimeout), and not unregister it if it was + * recurring (setInterval). + */ + callTimer: function(timerID) { + warning(timerID <= JSTimersExecution.GUID, 'Tried to call timer with ID ' + timerID + ' but no such timer exists'); + var timerIndex = JSTimersExecution.timerIDs.indexOf(timerID); + // timerIndex of -1 means that no timer with that ID exists. There are + // two situations when this happens, when a garbage timer ID was given + // and when a previously existing timer was deleted before this callback + // fired. In both cases we want to ignore the timer id, but in the former + // case we warn as well. + if (timerIndex === -1) { + return; + } + var type = JSTimersExecution.types[timerIndex]; + var callback = JSTimersExecution.callbacks[timerIndex]; + + // Clear the metadata + if (type === JSTimersExecution.Type.setTimeout || + type === JSTimersExecution.Type.setImmediate || + type === JSTimersExecution.Type.requestAnimationFrame) { + JSTimersExecution._clearIndex(timerIndex); + } + + try { + if (type === JSTimersExecution.Type.setTimeout || + type === JSTimersExecution.Type.setInterval || + type === JSTimersExecution.Type.setImmediate) { + callback(); + } else if (type === JSTimersExecution.Type.requestAnimationFrame) { + var currentTime = performanceNow(); + callback(currentTime); + } else { + console.error('Tried to call a callback with invalid type: ' + type); + return; + } + } catch (e) { + // Don't rethrow so that we can run every other timer. + JSTimersExecution.errors = JSTimersExecution.errors || []; + JSTimersExecution.errors.push(e); + } + }, + + /** + * This is called from the native side. We are passed an array of timerIDs, + * and + */ + callTimers: function(timerIDs) { + invariant(timerIDs.length !== 0, 'Probably shouldn\'t call "callTimers" with no timerIDs'); + + JSTimersExecution.errors = null; + timerIDs.forEach(JSTimersExecution.callTimer); + + var errors = JSTimersExecution.errors; + if (errors) { + var errorCount = errors.length; + if (errorCount > 1) { + // Throw all the other errors in a setTimeout, which will throw each + // error one at a time + for (var ii = 1; ii < errorCount; ii++) { + require('JSTimers').setTimeout( + ((error) => { throw error; }).bind(null, errors[ii]), + 0 + ); + } + } + throw errors[0]; + } + }, + + /** + * This is called after we execute any command we receive from native but + * before we hand control back to native. + */ + callImmediates: function() { + JSTimersExecution.errors = null; + while (JSTimersExecution.immediates.length !== 0) { + JSTimersExecution.callTimer(JSTimersExecution.immediates.shift()); + } + if (JSTimersExecution.errors) { + JSTimersExecution.errors.forEach((error) => + require('JSTimers').setTimeout(() => { throw error; }, 0) + ); + } + }, + + _clearIndex: function(i) { + JSTimersExecution.timerIDs[i] = null; + JSTimersExecution.callbacks[i] = null; + JSTimersExecution.types[i] = null; + }, +}; + +module.exports = JSTimersExecution; diff --git a/Libraries/RKBackendNode/queryLayoutByID.js b/Libraries/RKBackendNode/queryLayoutByID.js new file mode 100644 index 0000000000..7dcd2487ef --- /dev/null +++ b/Libraries/RKBackendNode/queryLayoutByID.js @@ -0,0 +1,39 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule queryLayoutByID + */ +'use strict'; + +var ReactIOSTagHandles = require('ReactIOSTagHandles'); +var RKUIManager = require('NativeModulesDeprecated').RKUIManager; + +/** + * Queries the layout of a view. The layout does not reflect the element as + * seen by the user, rather it reflects the position within the layout system, + * before any transforms are applied. + * + * The only other requirement is that the `pageX, pageY` values be in the same + * coordinate system that events' `pageX/Y` are reported. That means that for + * the web, `pageXOffset/pageYOffset` should be added to to + * getBoundingClientRect to make consistent with touches. + * + * var pageXOffset = window.pageXOffset; + * var pageYOffset = window.pageYOffset; + * + * This is an IOS specific implementation. + * + * @param {string} rootNodeID ID of the platform specific node to be measured. + * @param {function} onError `func(error)` + * @param {function} onSuccess `func(left, top, width, height, pageX, pageY)` + */ +var queryLayoutByID = function(rootNodeID, onError, onSuccess) { + // Native bridge doesn't *yet* surface errors. + RKUIManager.measure( + ReactIOSTagHandles.rootNodeIDToTag[rootNodeID], + onSuccess + ); +}; + +module.exports = queryLayoutByID; + diff --git a/Libraries/ReactIOS/IOSDefaultEventPluginOrder.js b/Libraries/ReactIOS/IOSDefaultEventPluginOrder.js new file mode 100644 index 0000000000..51c951719b --- /dev/null +++ b/Libraries/ReactIOS/IOSDefaultEventPluginOrder.js @@ -0,0 +1,13 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule IOSDefaultEventPluginOrder + */ +'use strict'; + +var IOSDefaultEventPluginOrder = [ + 'ResponderEventPlugin', + 'IOSNativeBridgeEventPlugin' +]; + +module.exports = IOSDefaultEventPluginOrder; diff --git a/Libraries/ReactIOS/IOSNativeBridgeEventPlugin.js b/Libraries/ReactIOS/IOSNativeBridgeEventPlugin.js new file mode 100644 index 0000000000..e698ad1967 --- /dev/null +++ b/Libraries/ReactIOS/IOSNativeBridgeEventPlugin.js @@ -0,0 +1,72 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule IOSNativeBridgeEventPlugin + */ + +"use strict"; + +var EventPropagators = require('EventPropagators'); +var NativeModulesDeprecated = require('NativeModulesDeprecated'); +var SyntheticEvent = require('SyntheticEvent'); + +var merge = require('merge'); +var warning = require('warning'); + +var RKUIManager = NativeModulesDeprecated.RKUIManager; + +var customBubblingEventTypes = RKUIManager.customBubblingEventTypes; +var customDirectEventTypes = RKUIManager.customDirectEventTypes; + +var allTypesByEventName = {}; + +for (var bubblingTypeName in customBubblingEventTypes) { + allTypesByEventName[bubblingTypeName] = customBubblingEventTypes[bubblingTypeName]; +} + +for (var directTypeName in customDirectEventTypes) { + warning( + !customBubblingEventTypes[directTypeName], + "Event cannot be both direct and bubbling: %s", + directTypeName + ); + allTypesByEventName[directTypeName] = customDirectEventTypes[directTypeName]; +} + +var IOSNativeBridgeEventPlugin = { + + eventTypes: merge(customBubblingEventTypes, customDirectEventTypes), + + /** + * @param {string} topLevelType Record from `EventConstants`. + * @param {DOMEventTarget} topLevelTarget The listening component root node. + * @param {string} topLevelTargetID ID of `topLevelTarget`. + * @param {object} nativeEvent Native browser event. + * @return {*} An accumulation of synthetic events. + * @see {EventPluginHub.extractEvents} + */ + extractEvents: function( + topLevelType, + topLevelTarget, + topLevelTargetID, + nativeEvent) { + var bubbleDispatchConfig = customBubblingEventTypes[topLevelType]; + var directDispatchConfig = customDirectEventTypes[topLevelType]; + var event = SyntheticEvent.getPooled( + bubbleDispatchConfig || directDispatchConfig, + topLevelTargetID, + nativeEvent + ); + if (bubbleDispatchConfig) { + EventPropagators.accumulateTwoPhaseDispatches(event); + } else if (directDispatchConfig) { + EventPropagators.accumulateDirectDispatches(event); + } else { + return null; + } + return event; + } +}; + +module.exports = IOSNativeBridgeEventPlugin; + diff --git a/Libraries/ReactIOS/NativeMethodsMixin.js b/Libraries/ReactIOS/NativeMethodsMixin.js new file mode 100644 index 0000000000..ebfc246f0a --- /dev/null +++ b/Libraries/ReactIOS/NativeMethodsMixin.js @@ -0,0 +1,123 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule NativeMethodsMixin + */ +'use strict'; + +var NativeModules = require('NativeModules'); +var NativeModulesDeprecated = require('NativeModulesDeprecated'); +var RKUIManager = NativeModules.RKUIManager; +var RKUIManagerDeprecated = NativeModulesDeprecated.RKUIManager; +var RKPOPAnimationManagerDeprecated = NativeModulesDeprecated.RKPOPAnimationManager; +var TextInputState = require('TextInputState'); + +var flattenStyle = require('flattenStyle'); +var invariant = require('invariant'); +var mergeFast = require('mergeFast'); + +var animationIDInvariant = function(funcName, anim) { + invariant( + anim, + funcName + ' must be called with a valid animation ID returned from' + + ' POPAnimation.createAnimation, received: "' + anim + '"' + ); +}; + +var NativeMethodsMixin = { + addAnimation: function(anim, callback) { + animationIDInvariant('addAnimation', anim); + RKPOPAnimationManagerDeprecated.addAnimation(this.getNodeHandle(), anim, callback); + }, + + removeAnimation: function(anim) { + animationIDInvariant('removeAnimation', anim); + RKPOPAnimationManagerDeprecated.removeAnimation(this.getNodeHandle(), anim); + }, + + measure: function(callback) { + RKUIManagerDeprecated.measure(this.getNodeHandle(), callback); + }, + + measureLayout: function(relativeToNativeNode, onSuccess, onFail) { + RKUIManager.measureLayout( + this.getNodeHandle(), + relativeToNativeNode, + onFail, + onSuccess + ); + }, + + /** + * This function sends props straight to native. They will not participate + * in future diff process, this means that if you do not include them in the + * next render, they will remain active. + */ + setNativeProps: function(nativeProps) { + // nativeProps contains a style attribute that's going to be flattened + // and all the attributes expanded in place. In order to make this + // process do as few allocations and copies as possible, we return + // one if the other is empty. Only if both have values then we create + // a new object and merge. + var hasOnlyStyle = true; + for (var key in nativeProps) { + if (key !== 'style') { + hasOnlyStyle = false; + break; + } + } + var style = flattenStyle(nativeProps.style); + + var props = null; + if (hasOnlyStyle) { + props = style; + } else if (!style) { + props = nativeProps; + } else { + props = mergeFast(nativeProps, style); + } + + RKUIManagerDeprecated.updateView( + this.getNodeHandle(), + this.viewConfig.uiViewClassName, + props + ); + }, + + focus: function() { + TextInputState.focusTextInput(this.getNodeHandle()); + }, + + blur: function() { + TextInputState.blurTextInput(this.getNodeHandle()); + } +}; + +function throwOnStylesProp(component, props) { + if (props.styles !== undefined) { + var owner = component._owner || null; + var name = component.constructor.displayName; + var msg = '`styles` is not a supported property of `' + name + '`, did ' + + 'you mean `style` (singular)?'; + if (owner && owner.constructor && owner.constructor.displayName) { + msg += '\n\nCheck the `' + owner.constructor.displayName + '` parent ' + + ' component.'; + } + throw new Error(msg); + } +} +if (__DEV__) { + invariant( + !NativeMethodsMixin.componentWillMount && + !NativeMethodsMixin.componentWillReceiveProps, + 'Do not override existing functions.' + ); + NativeMethodsMixin.componentWillMount = function () { + throwOnStylesProp(this, this.props); + }; + NativeMethodsMixin.componentWillReceiveProps = function (newProps) { + throwOnStylesProp(this, newProps); + }; +} + +module.exports = NativeMethodsMixin; diff --git a/Libraries/ReactIOS/NativeModules/RKRawText.js b/Libraries/ReactIOS/NativeModules/RKRawText.js new file mode 100644 index 0000000000..c56f0f68f7 --- /dev/null +++ b/Libraries/ReactIOS/NativeModules/RKRawText.js @@ -0,0 +1,21 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule RKRawText + * @typechecks static-only + */ + +"use strict"; + +var ReactIOSViewAttributes = require('ReactIOSViewAttributes'); + +var createReactIOSNativeComponentClass = require('createReactIOSNativeComponentClass'); + +var RKRawText = createReactIOSNativeComponentClass({ + validAttributes: { + text: true, + }, + uiViewClassName: 'RCTRawText', +}); + +module.exports = RKRawText; diff --git a/Libraries/ReactIOS/React.js b/Libraries/ReactIOS/React.js new file mode 100644 index 0000000000..63de6d84ca --- /dev/null +++ b/Libraries/ReactIOS/React.js @@ -0,0 +1,21 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @providesModule React + */ + +"use strict"; + +module.exports = require('ReactIOS'); diff --git a/Libraries/ReactIOS/ReactIOS.js b/Libraries/ReactIOS/ReactIOS.js new file mode 100644 index 0000000000..f0a1ed7828 --- /dev/null +++ b/Libraries/ReactIOS/ReactIOS.js @@ -0,0 +1,117 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ReactIOS + */ + +"use strict"; + +var ReactComponent = require('ReactComponent'); +var ReactCompositeComponent = require('ReactCompositeComponent'); +var ReactContext = require('ReactContext'); +var ReactCurrentOwner = require('ReactCurrentOwner'); +var ReactElement = require('ReactElement'); +var ReactElementValidator = require('ReactElementValidator'); +var ReactInstanceHandles = require('ReactInstanceHandles'); +var ReactIOSDefaultInjection = require('ReactIOSDefaultInjection'); +var ReactIOSMount = require('ReactIOSMount'); +var ReactLegacyElement = require('ReactLegacyElement'); +var ReactPropTypes = require('ReactPropTypes'); + +var deprecated = require('deprecated'); +var invariant = require('invariant'); + +ReactIOSDefaultInjection.inject(); + +var createElement = ReactElement.createElement; +var createFactory = ReactElement.createFactory; + +if (__DEV__) { + createElement = ReactElementValidator.createElement; + createFactory = ReactElementValidator.createFactory; +} + +// TODO: Drop legacy elements once classes no longer export these factories +createElement = ReactLegacyElement.wrapCreateElement( + createElement +); +createFactory = ReactLegacyElement.wrapCreateFactory( + createFactory +); + +var resolveDefaultProps = function(element) { + // Could be optimized, but not currently in heavy use. + var defaultProps = element.type.defaultProps; + var props = element.props; + for (var propName in defaultProps) { + if (props[propName] === undefined) { + props[propName] = defaultProps[propName]; + } + } +}; + +// Experimental optimized element creation +var augmentElement = function(element) { + if (__DEV__) { + invariant( + false, + 'This optimized path should never be used in DEV mode because ' + + 'it does not provide validation. Check your JSX transform.' + ); + } + element._owner = ReactCurrentOwner.current; + element._context = ReactContext.current; + if (element.type.defaultProps) { + resolveDefaultProps(element); + } + return element; +}; + +var render = function(component, mountInto) { + ReactIOSMount.renderComponent(component, mountInto); +}; + +var ReactIOS = { + hasReactIOSInitialized: false, + PropTypes: ReactPropTypes, + createClass: ReactCompositeComponent.createClass, + createElement: createElement, + createFactory: createFactory, + _augmentElement: augmentElement, + render: render, + unmountComponentAtNode: ReactIOSMount.unmountComponentAtNode, + /** + * Used by the debugger. + */ + __internals: { + Component: ReactComponent, + CurrentOwner: ReactCurrentOwner, + InstanceHandles: ReactInstanceHandles, + Mount: ReactIOSMount, + }, + + // Hook for JSX spread, don't use this for anything else. + __spread: Object.assign, + + unmountComponentAtNodeAndRemoveContainer: ReactIOSMount.unmountComponentAtNodeAndRemoveContainer, + isValidClass: ReactElement.isValidFactory, + isValidElement: ReactElement.isValidElement, + + // Deprecations (remove for 0.13) + renderComponent: deprecated( + 'React', + 'renderComponent', + 'render', + this, + render + ), + isValidComponent: deprecated( + 'React', + 'isValidComponent', + 'isValidElement', + this, + ReactElement.isValidElement + ) +}; + +module.exports = ReactIOS; diff --git a/Libraries/ReactIOS/ReactIOSComponentEnvironment.js b/Libraries/ReactIOS/ReactIOSComponentEnvironment.js new file mode 100644 index 0000000000..67da68b134 --- /dev/null +++ b/Libraries/ReactIOS/ReactIOSComponentEnvironment.js @@ -0,0 +1,68 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ReactIOSComponentEnvironment + */ +'use strict'; +var RKUIManager = require('NativeModulesDeprecated').RKUIManager; + +var ReactIOSDOMIDOperations = require('ReactIOSDOMIDOperations'); +var ReactIOSReconcileTransaction = require('ReactIOSReconcileTransaction'); +var ReactIOSTagHandles = require('ReactIOSTagHandles'); +var ReactPerf = require('ReactPerf'); + +var ReactIOSComponentEnvironment = { + + /** + * Will need to supply something that implements this. + */ + BackendIDOperations: ReactIOSDOMIDOperations, + + /** + * Nothing to do for UIKit bridge. + * + * @private + */ + unmountIDFromEnvironment: function(/*rootNodeID*/) { + + }, + + /** + * @param {DOMElement} Element to clear. + */ + clearNode: function(/*containerView*/) { + + }, + + /** + * @param {View} view View tree image. + * @param {number} containerViewID View to insert sub-view into. + */ + mountImageIntoNode: ReactPerf.measure( + // FIXME(frantic): #4441289 Hack to avoid modifying react-tools + 'ReactComponentBrowserEnvironment', + 'mountImageIntoNode', + function(mountImage, containerID) { + // Since we now know that the `mountImage` has been mounted, we can + // mark it as such. + ReactIOSTagHandles.associateRootNodeIDWithMountedNodeHandle( + mountImage.rootNodeID, + mountImage.tag + ); + var addChildTags = [mountImage.tag]; + var addAtIndices = [0]; + RKUIManager.manageChildren( + ReactIOSTagHandles.mostRecentMountedNodeHandleForRootNodeID(containerID), + null, // moveFromIndices + null, // moveToIndices + addChildTags, + addAtIndices, + null // removeAtIndices + ); + } + ), + + ReactReconcileTransaction: ReactIOSReconcileTransaction, +}; + +module.exports = ReactIOSComponentEnvironment; diff --git a/Libraries/ReactIOS/ReactIOSComponentMixin.js b/Libraries/ReactIOS/ReactIOSComponentMixin.js new file mode 100644 index 0000000000..7abb213eef --- /dev/null +++ b/Libraries/ReactIOS/ReactIOSComponentMixin.js @@ -0,0 +1,66 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ReactIOSComponentMixin + */ +'use strict'; + +var ReactIOSTagHandles = require('ReactIOSTagHandles'); + +/** + * ReactNative vs ReactWeb + * ----------------------- + * React treats some pieces of data opaquely. This means that the information + * is first class (it can be passed around), but cannot be inspected. This + * allows us to build infrastructure that reasons about resources, without + * making assumptions about the nature of those resources, and this allows that + * infra to be shared across multiple platforms, where the resources are very + * different. General infra (such as `ReactMultiChild`) reasons opaquely about + * the data, but platform specific code (such as `ReactIOSNativeComponent`) can + * make assumptions about the data. + * + * + * `rootNodeID`, uniquely identifies a position in the generated native view + * tree. Many layers of composite components (created with `React.createClass`) + * can all share the same `rootNodeID`. + * + * `nodeHandle`: A sufficiently unambiguous way to refer to a lower level + * resource (dom node, native view etc). The `rootNodeID` is sufficient for web + * `nodeHandle`s, because the position in a tree is always enough to uniquely + * identify a DOM node (we never have nodes in some bank outside of the + * document). The same would be true for `ReactNative`, but we must maintain a + * mapping that we can send efficiently serializable + * strings across native boundaries. + * + * Opaque name TodaysWebReact FutureWebWorkerReact ReactNative + * ---------------------------------------------------------------------------- + * nodeHandle N/A rootNodeID tag + * + * + * `mountImage`: A way to represent the potential to create lower level + * resources whos `nodeHandle` can be discovered immediately by knowing the + * `rootNodeID`. Today's web react represents this with `innerHTML` annotated + * with DOM ids that match the `rootNodeID`. + * + * Opaque name TodaysWebReact FutureWebWorkerReact ReactNative + * ---------------------------------------------------------------------------- + * mountImage innerHTML innerHTML {rootNodeID, tag} + * + */ +var ReactIOSComponentMixin = { + /** + * This has no particular meaning in ReactIOS. If this were in the DOM, this + * would return the DOM node. There should be nothing that invokes this + * method. Any current callers of this are mistaken - they should be invoking + * `getNodeHandle`. + */ + getNativeNode: function() { + return ReactIOSTagHandles.rootNodeIDToTag[this._rootNodeID]; + }, + + getNodeHandle: function() { + return ReactIOSTagHandles.rootNodeIDToTag[this._rootNodeID]; + } +}; + +module.exports = ReactIOSComponentMixin; diff --git a/Libraries/ReactIOS/ReactIOSDOMIDOperations.js b/Libraries/ReactIOS/ReactIOSDOMIDOperations.js new file mode 100644 index 0000000000..51a5201557 --- /dev/null +++ b/Libraries/ReactIOS/ReactIOSDOMIDOperations.js @@ -0,0 +1,98 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ReactIOSDOMIDOperations + * @typechecks static-only + */ + +"use strict"; + +var ReactIOSTagHandles = require('ReactIOSTagHandles'); +var ReactMultiChildUpdateTypes = require('ReactMultiChildUpdateTypes'); +var RKUIManager = require('NativeModulesDeprecated').RKUIManager; +var ReactPerf = require('ReactPerf'); + +/** + * Updates a component's children by processing a series of updates. + * For each of the update/create commands, the `fromIndex` refers to the index + * that the item existed at *before* any of the updates are applied, and the + * `toIndex` refers to the index after *all* of the updates are applied + * (including deletes/moves). TODO: refactor so this can be shared with + * DOMChildrenOperations. + * + * @param {array} updates List of update configurations. + * @param {array} markup List of markup strings - in the case of React + * IOS, the ids of new components assumed to be already created. + */ +var dangerouslyProcessChildrenUpdates = function(childrenUpdates, markupList) { + if (!childrenUpdates.length) { + return; + } + var byContainerTag = {}; + // Group by parent ID - send them across the bridge in separate commands per + // containerID. + for (var i = 0; i < childrenUpdates.length; i++) { + var update = childrenUpdates[i]; + var containerTag = ReactIOSTagHandles.mostRecentMountedNodeHandleForRootNodeID(update.parentID); + var updates = byContainerTag[containerTag] || (byContainerTag[containerTag] = {}); + if (update.type === ReactMultiChildUpdateTypes.MOVE_EXISTING) { + (updates.moveFromIndices || (updates.moveFromIndices = [])).push(update.fromIndex); + (updates.moveToIndices || (updates.moveToIndices = [])).push(update.toIndex); + } else if (update.type === ReactMultiChildUpdateTypes.REMOVE_NODE) { + (updates.removeAtIndices || (updates.removeAtIndices = [])).push(update.fromIndex); + } else if (update.type === ReactMultiChildUpdateTypes.INSERT_MARKUP) { + var mountImage = markupList[update.markupIndex]; + var tag = mountImage.tag; + var rootNodeID = mountImage.rootNodeID; + ReactIOSTagHandles.associateRootNodeIDWithMountedNodeHandle(rootNodeID, tag); + (updates.addAtIndices || (updates.addAtIndices = [])).push(update.toIndex); + (updates.addChildTags || (updates.addChildTags = [])).push(tag); + } + } + // Note this enumeration order will be different on V8! Move `byContainerTag` + // to a sparse array as soon as we confirm there are not horrible perf + // penalties. + for (var updateParentTagString in byContainerTag) { + var updateParentTagNumber = +updateParentTagString; + var childUpdatesToSend = byContainerTag[updateParentTagNumber]; + RKUIManager.manageChildren( + updateParentTagNumber, + childUpdatesToSend.moveFromIndices, + childUpdatesToSend.moveToIndices, + childUpdatesToSend.addChildTags, + childUpdatesToSend.addAtIndices, + childUpdatesToSend.removeAtIndices + ); + } +}; + +/** + * Operations used to process updates to DOM nodes. This is made injectable via + * `ReactComponent.DOMIDOperations`. + */ +var ReactIOSDOMIDOperations = { + dangerouslyProcessChildrenUpdates: ReactPerf.measure( + // FIXME(frantic): #4441289 Hack to avoid modifying react-tools + 'ReactDOMIDOperations', + 'dangerouslyProcessChildrenUpdates', + dangerouslyProcessChildrenUpdates + ), + + /** + * Replaces a view that exists in the document with markup. + * + * @param {string} id ID of child to be replaced. + * @param {string} markup Mount image to replace child with id. + */ + dangerouslyReplaceNodeWithMarkupByID: ReactPerf.measure( + 'ReactDOMIDOperations', + 'dangerouslyReplaceNodeWithMarkupByID', + function(id, mountImage) { + var oldTag = ReactIOSTagHandles.mostRecentMountedNodeHandleForRootNodeID(id); + RKUIManager.replaceExistingNonRootView(oldTag, mountImage.tag); + ReactIOSTagHandles.associateRootNodeIDWithMountedNodeHandle(id, mountImage.tag); + } + ), +}; + +module.exports = ReactIOSDOMIDOperations; diff --git a/Libraries/ReactIOS/ReactIOSDefaultInjection.js b/Libraries/ReactIOS/ReactIOSDefaultInjection.js new file mode 100644 index 0000000000..729c294e5c --- /dev/null +++ b/Libraries/ReactIOS/ReactIOSDefaultInjection.js @@ -0,0 +1,94 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ReactIOSDefaultInjection + */ + +"use strict"; + +/** + * Make sure `setTimeout`/`setInterval` are patched correctly. + */ +require('InitializeJavaScriptAppEngine'); +var EventPluginHub = require('EventPluginHub'); +var EventPluginUtils = require('EventPluginUtils'); +var IOSDefaultEventPluginOrder = require('IOSDefaultEventPluginOrder'); +var IOSNativeBridgeEventPlugin = require('IOSNativeBridgeEventPlugin'); +var NodeHandle = require('NodeHandle'); +var ReactComponent = require('ReactComponent'); +var ReactCompositeComponent = require('ReactCompositeComponent'); +var ReactDefaultBatchingStrategy = require('ReactDefaultBatchingStrategy'); +var ReactElement = require('ReactElement'); +var ReactInstanceHandles = require('ReactInstanceHandles'); +var ReactIOSComponentEnvironment = require('ReactIOSComponentEnvironment'); +var ReactIOSComponentMixin = require('ReactIOSComponentMixin'); +var ReactIOSGlobalInteractionHandler = require('ReactIOSGlobalInteractionHandler'); +var ReactIOSGlobalResponderHandler = require('ReactIOSGlobalResponderHandler'); +var ReactIOSMount = require('ReactIOSMount'); +var ReactTextComponent = require('ReactTextComponent'); +var ReactUpdates = require('ReactUpdates'); +var ResponderEventPlugin = require('ResponderEventPlugin'); +var RKRawText = require('RKRawText'); +var UniversalWorkerNodeHandle = require('UniversalWorkerNodeHandle'); + +// Just to ensure this gets packaged, since its only caller is from Native. +require('RCTEventEmitter'); +require('RCTLog'); +require('RCTJSTimers'); + +function inject() { + /** + * Inject module for resolving DOM hierarchy and plugin ordering. + */ + EventPluginHub.injection.injectEventPluginOrder(IOSDefaultEventPluginOrder); + EventPluginHub.injection.injectInstanceHandle(ReactInstanceHandles); + + ResponderEventPlugin.injection.injectGlobalResponderHandler( + ReactIOSGlobalResponderHandler + ); + + ResponderEventPlugin.injection.injectGlobalInteractionHandler( + ReactIOSGlobalInteractionHandler + ); + + /** + * Some important event plugins included by default (without having to require + * them). + */ + EventPluginHub.injection.injectEventPluginsByName({ + 'ResponderEventPlugin': ResponderEventPlugin, + 'IOSNativeBridgeEventPlugin': IOSNativeBridgeEventPlugin + }); + + ReactUpdates.injection.injectReconcileTransaction( + ReactIOSComponentEnvironment.ReactReconcileTransaction + ); + + ReactUpdates.injection.injectBatchingStrategy( + ReactDefaultBatchingStrategy + ); + + ReactComponent.injection.injectEnvironment( + ReactIOSComponentEnvironment + ); + + EventPluginUtils.injection.injectMount(ReactIOSMount); + + ReactCompositeComponent.injection.injectMixin(ReactIOSComponentMixin); + + ReactTextComponent.inject(function(initialText) { + // RKRawText is a class so we can't invoke it directly. Instead of using + // a factory, we use the internal fast path to create a descriptor. + // RKRawText is not quite a class yet, so we access the real class from + // the type property. TODO: Change this once factory wrappers are gone. + return new ReactElement(RKRawText.type, null, null, null, null, { + text: initialText + }); + }); + + NodeHandle.injection.injectImplementation(UniversalWorkerNodeHandle); +} + +module.exports = { + inject: inject, +}; diff --git a/Libraries/ReactIOS/ReactIOSEventEmitter.js b/Libraries/ReactIOS/ReactIOSEventEmitter.js new file mode 100644 index 0000000000..ab768c9277 --- /dev/null +++ b/Libraries/ReactIOS/ReactIOSEventEmitter.js @@ -0,0 +1,191 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ReactIOSEventEmitter + * @typechecks static-only + */ + +"use strict"; + +var EventPluginHub = require('EventPluginHub'); +var ReactEventEmitterMixin = require('ReactEventEmitterMixin'); +var ReactIOSTagHandles = require('ReactIOSTagHandles'); +var NodeHandle = require('NodeHandle'); +var EventConstants = require('EventConstants'); + +var merge = require('merge'); +var warning = require('warning'); + +var topLevelTypes = EventConstants.topLevelTypes; + +/** + * Version of `ReactBrowserEventEmitter` that works on the receiving side of a + * serialized worker boundary. + */ + +// Shared default empty native event - conserve memory. +var EMPTY_NATIVE_EVENT = {}; + +/** + * Selects a subsequence of `Touch`es, without destroying `touches`. + * + * @param {Array} touches Deserialized touch objects. + * @param {Array} indices Indices by which to pull subsequence. + * @return {Array} Subsequence of touch objects. + */ +var touchSubsequence = function(touches, indices) { + var ret = []; + for (var i = 0; i < indices.length; i++) { + ret.push(touches[indices[i]]); + } + return ret; +}; + +/** + * TODO: Pool all of this. + * + * Destroys `touches` by removing touch objects at indices `indices`. This is + * to maintain compatibility with W3C touch "end" events, where the active + * touches don't include the set that has just been "ended". + * + * @param {Array} touches Deserialized touch objects. + * @param {Array} indices Indices to remove from `touches`. + * @return {Array} Subsequence of removed touch objects. + */ +var removeTouchesAtIndices = function(touches, indices) { + var rippedOut = []; + for (var i = 0; i < indices.length; i++) { + var index = indices[i]; + rippedOut.push(touches[index]); + touches[index] = null; + } + var fillAt = 0; + for (var j = 0; j < touches.length; j++) { + var cur = touches[j]; + if (cur !== null) { + touches[fillAt++] = cur; + } + } + touches.length = fillAt; + return rippedOut; +}; + +/** + * `ReactIOSEventEmitter` is used to attach top-level event listeners. For example: + * + * ReactIOSEventEmitter.putListener('myID', 'onClick', myFunction); + * + * This would allocate a "registration" of `('onClick', myFunction)` on 'myID'. + * + * @internal + */ +var ReactIOSEventEmitter = merge(ReactEventEmitterMixin, { + + registrationNames: EventPluginHub.registrationNameModules, + + putListener: EventPluginHub.putListener, + + getListener: EventPluginHub.getListener, + + deleteListener: EventPluginHub.deleteListener, + + deleteAllListeners: EventPluginHub.deleteAllListeners, + + /** + * Internal version of `receiveEvent` in terms of normalized (non-tag) + * `rootNodeID`. + * + * @see receiveEvent. + * + * @param {rootNodeID} rootNodeID React root node ID that event occured on. + * @param {TopLevelType} topLevelType Top level type of event. + * @param {object} nativeEventParam Object passed from native. + */ + _receiveRootNodeIDEvent: function(rootNodeID, topLevelType, nativeEventParam) { + var nativeEvent = nativeEventParam || EMPTY_NATIVE_EVENT; + ReactIOSEventEmitter.handleTopLevel( + topLevelType, + rootNodeID, + rootNodeID, + nativeEvent + ); + }, + + /** + * Publically exposed method on module for native objc to invoke when a top + * level event is extracted. + * @param {rootNodeID} rootNodeID React root node ID that event occured on. + * @param {TopLevelType} topLevelType Top level type of event. + * @param {object} nativeEventParam Object passed from native. + */ + receiveEvent: function(tag, topLevelType, nativeEventParam) { + var rootNodeID = ReactIOSTagHandles.tagToRootNodeID[tag]; + ReactIOSEventEmitter._receiveRootNodeIDEvent( + rootNodeID, + topLevelType, + nativeEventParam + ); + }, + + /** + * Simple multi-wrapper around `receiveEvent` that is intended to receive an + * efficient representation of `Touch` objects, and other information that + * can be used to construct W3C compliant `Event` and `Touch` lists. + * + * This may create dispatch behavior that differs than web touch handling. We + * loop through each of the changed touches and receive it as a single event. + * So two `touchStart`/`touchMove`s that occur simultaneously are received as + * two separate touch event dispatches - when they arguably should be one. + * + * This implementation reuses the `Touch` objects themselves as the `Event`s + * since we dispatch an event for each touch (though that might not be spec + * compliant). The main purpose of reusing them is to save allocations. + * + * TODO: Dispatch multiple changed touches in one event. The bubble path + * could be the first common ancestor of all the `changedTouches`. + * + * One difference between this behavior and W3C spec: cancelled touches will + * not appear in `.touches`, or in any future `.touches`, though they may + * still be "actively touching the surface". + * + * Web desktop polyfills only need to construct a fake touch event with + * identifier 0, also abandoning traditional click handlers. + */ + receiveTouches: function(eventTopLevelType, touches, changedIndices) { + var changedTouches = + eventTopLevelType === topLevelTypes.topTouchEnd || + eventTopLevelType === topLevelTypes.topTouchCancel ? + removeTouchesAtIndices(touches, changedIndices) : + touchSubsequence(touches, changedIndices); + + for (var jj = 0; jj < changedTouches.length; jj++) { + var touch = changedTouches[jj]; + // Touch objects can fullfill the role of `DOM` `Event` objects if we set + // the `changedTouches`/`touches`. This saves allocations. + touch.changedTouches = changedTouches; + touch.touches = touches; + var nativeEvent = touch; + var rootNodeID = null; + var target = nativeEvent.target; + if (target !== null && target !== undefined) { + if (target < ReactIOSTagHandles.tagsStartAt) { + if (__DEV__) { + warning( + false, + 'A view is reporting that a touch occured on tag zero.' + ); + } + } else { + rootNodeID = NodeHandle.getRootNodeID(target); + } + } + ReactIOSEventEmitter._receiveRootNodeIDEvent( + rootNodeID, + eventTopLevelType, + nativeEvent + ); + } + } +}); + +module.exports = ReactIOSEventEmitter; diff --git a/Libraries/ReactIOS/ReactIOSGlobalInteractionHandler.js b/Libraries/ReactIOS/ReactIOSGlobalInteractionHandler.js new file mode 100644 index 0000000000..2b9c994ac3 --- /dev/null +++ b/Libraries/ReactIOS/ReactIOSGlobalInteractionHandler.js @@ -0,0 +1,25 @@ +/** + * @providesModule ReactIOSGlobalInteractionHandler + */ +'use strict'; + +var InteractionManager = require('InteractionManager'); + +// Interaction handle is created/cleared when responder is granted or +// released/terminated. +var interactionHandle = null; + +var ReactIOSGlobalInteractionHandler = { + onChange: function(numberActiveTouches) { + if (numberActiveTouches === 0) { + if (interactionHandle) { + InteractionManager.clearInteractionHandle(interactionHandle); + interactionHandle = null; + } + } else if (!interactionHandle) { + interactionHandle = InteractionManager.createInteractionHandle(); + } + } +}; + +module.exports = ReactIOSGlobalInteractionHandler; diff --git a/Libraries/ReactIOS/ReactIOSGlobalResponderHandler.js b/Libraries/ReactIOS/ReactIOSGlobalResponderHandler.js new file mode 100644 index 0000000000..4a9c944109 --- /dev/null +++ b/Libraries/ReactIOS/ReactIOSGlobalResponderHandler.js @@ -0,0 +1,21 @@ +/** + * @providesModule ReactIOSGlobalResponderHandler + */ +'use strict'; + +var RKUIManager = require('NativeModules').RKUIManager; +var ReactIOSTagHandles = require('ReactIOSTagHandles'); + +var ReactIOSGlobalResponderHandler = { + onChange: function(from, to) { + if (to !== null) { + RKUIManager.setJSResponder( + ReactIOSTagHandles.mostRecentMountedNodeHandleForRootNodeID(to) + ); + } else { + RKUIManager.clearJSResponder(); + } + } +}; + +module.exports = ReactIOSGlobalResponderHandler; diff --git a/Libraries/ReactIOS/ReactIOSMount.js b/Libraries/ReactIOS/ReactIOSMount.js new file mode 100644 index 0000000000..4e29be92f5 --- /dev/null +++ b/Libraries/ReactIOS/ReactIOSMount.js @@ -0,0 +1,123 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ReactIOSMount + */ +'use strict'; + +var RKUIManager = require('NativeModulesDeprecated').RKUIManager; + +var ReactIOSTagHandles = require('ReactIOSTagHandles'); +var ReactPerf = require('ReactPerf'); + +var instantiateReactComponent = require('instantiateReactComponent'); +var invariant = require('invariant'); + +var TOP_ROOT_NODE_IDS = {}; + +function instanceNumberToChildRootID(rootNodeID, instanceNumber) { + return rootNodeID + '[' + instanceNumber + ']'; +} + +/** + * As soon as `ReactMount` is refactored to not rely on the DOM, we can share + * code between the two. For now, we'll hard code the ID logic. + */ +var ReactIOSMount = { + instanceCount: 0, + + _instancesByContainerID: {}, + + /** + * @param {ReactComponent} instance Instance to render. + * @param {containerTag} containerView Handle to native view tag + */ + renderComponent: function(descriptor, containerTag) { + var instance = instantiateReactComponent(descriptor); + + if (!ReactIOSTagHandles.reactTagIsNativeTopRootID(containerTag)) { + console.error('You cannot render into anything but a top root'); + return; + } + + var topRootNodeID = ReactIOSTagHandles.allocateRootNodeIDForTag(containerTag); + ReactIOSTagHandles.associateRootNodeIDWithMountedNodeHandle( + topRootNodeID, + containerTag + ); + TOP_ROOT_NODE_IDS[topRootNodeID] = true; + + var childRootNodeID = instanceNumberToChildRootID( + topRootNodeID, + ReactIOSMount.instanceCount++ + ); + ReactIOSMount._instancesByContainerID[topRootNodeID] = instance; + instance.mountComponentIntoNode(childRootNodeID, topRootNodeID); + }, + + /** + * Standard unmounting of the component that is rendered into `containerID`, + * but will also execute a command to remove the actual container view + * itself. This is useful when a client is cleaning up a React tree, and also + * knows that the container will no longer be needed. When executing + * asynchronously, it's easier to just have this method be the one that calls + * for removal of the view. + */ + unmountComponentAtNodeAndRemoveContainer: function(containerTag) { + ReactIOSMount.unmountComponentAtNode(containerTag); + // call back into native to remove all of the subviews from this container + RKUIManager.removeRootView(containerTag); + }, + + /** + * Unmount component at container ID by iterating through each child component + * that has been rendered and unmounting it. There should just be one child + * component at this time. + */ + unmountComponentAtNode: function(containerTag) { + var containerID = ReactIOSTagHandles.tagToRootNodeID[containerTag]; + + invariant( + TOP_ROOT_NODE_IDS[containerID], + 'We only currently support removing components from the root node' + ); + var instance = ReactIOSMount._instancesByContainerID[containerID]; + if (!instance) { + console.error('Tried to unmount a component that does not exist'); + return false; + } + ReactIOSMount.unmountComponentFromNode(instance, containerID); + delete ReactIOSMount._instancesByContainerID[containerID]; + delete TOP_ROOT_NODE_IDS[containerID]; + return true; + }, + + /** + * Unmounts a component and sends messages back to iOS to remove its subviews. + * + * @param {ReactComponent} instance React component instance. + * @param {int} containerID ID of container we're removing from. + * @final + * @internal + * @see {ReactIOSMount.unmountComponentAtNode} + */ + unmountComponentFromNode: function(instance, containerID) { + // call back into native to remove all of the subviews from this container + instance.unmountComponent(); + var containerTag = + ReactIOSTagHandles.mostRecentMountedNodeHandleForRootNodeID(containerID); + RKUIManager.removeSubviewsFromContainerWithID(containerTag); + }, + + getNode: function(id) { + return id; + } +}; + +ReactIOSMount.renderComponent = ReactPerf.measure( + 'ReactMount', + '_renderNewRootComponent', + ReactIOSMount.renderComponent +); + +module.exports = ReactIOSMount; diff --git a/Libraries/ReactIOS/ReactIOSNativeComponent.js b/Libraries/ReactIOS/ReactIOSNativeComponent.js new file mode 100644 index 0000000000..7c8381855a --- /dev/null +++ b/Libraries/ReactIOS/ReactIOSNativeComponent.js @@ -0,0 +1,265 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ReactIOSNativeComponent + */ +'use strict'; + +var NativeMethodsMixin = require('NativeMethodsMixin'); +var ReactComponent = require('ReactComponent'); +var ReactIOSComponentMixin = require('ReactIOSComponentMixin'); +var ReactIOSEventEmitter = require('ReactIOSEventEmitter'); +var ReactIOSStyleAttributes = require('ReactIOSStyleAttributes'); +var ReactIOSTagHandles = require('ReactIOSTagHandles'); +var ReactMultiChild = require('ReactMultiChild'); +var RKUIManager = require('NativeModulesDeprecated').RKUIManager; + +var styleDiffer = require('styleDiffer'); +var deepFreezeAndThrowOnMutationInDev = require('deepFreezeAndThrowOnMutationInDev'); +var diffRawProperties = require('diffRawProperties'); +var flattenStyle = require('flattenStyle'); +var warning = require('warning'); + +var registrationNames = ReactIOSEventEmitter.registrationNames; +var putListener = ReactIOSEventEmitter.putListener; +var deleteAllListeners = ReactIOSEventEmitter.deleteAllListeners; + +/** + * @constructor ReactIOSNativeComponent + * @extends ReactComponent + * @extends ReactMultiChild + * @param {!object} UIKit View Configuration. + */ +var ReactIOSNativeComponent = function(viewConfig) { + this.viewConfig = viewConfig; + this.props = null; + this.previousFlattenedStyle = null; +}; + +/** + * Generates and caches arrays of the form: + * + * [0, 1, 2, 3] + * [0, 1, 2, 3, 4] + * [0, 1] + * + * @param {number} size Size of array to generate. + * @return {Array} Array with values that mirror the index. + */ +var cachedIndexArray = function(size) { + var cachedResult = cachedIndexArray._cache[size]; + if (!cachedResult) { + var arr = []; + for (var i = 0; i < size; i++) { + arr[i] = i; + } + return cachedIndexArray._cache[size] = arr; + } else { + return cachedResult; + } +}; +cachedIndexArray._cache = {}; + +/** + * Mixin for containers that contain UIViews. NOTE: markup is rendered markup + * which is a `viewID` ... see the return value for `mountComponent` ! + */ +ReactIOSNativeComponent.Mixin = { + unmountComponent: function() { + deleteAllListeners(this._rootNodeID); + ReactComponent.Mixin.unmountComponent.call(this); + this.unmountChildren(); + }, + + /** + * Every native component is responsible for allocating its own `tag`, and + * issuing the native `createView` command. But it is not responsible for + * recording the fact that its own `rootNodeID` is associated with a + * `nodeHandle`. Only the code that actually adds its `nodeHandle` (`tag`) as + * a child of a container can confidently record that in + * `ReactIOSTagHandles`. + */ + initializeChildren: function(children, containerTag, transaction) { + var mountImages = this.mountChildren(children, transaction); + // In a well balanced tree, half of the nodes are in the bottom row and have + // no children - let's avoid calling out to the native bridge for a large + // portion of the children. + if (mountImages.length) { + var indexes = cachedIndexArray(mountImages.length); + // TODO: Pool these per platform view class. Reusing the `mountImages` + // array would likely be a jit deopt. + var createdTags = []; + for (var i = 0; i < mountImages.length; i++) { + var mountImage = mountImages[i]; + var childTag = mountImage.tag; + var childID = mountImage.rootNodeID; + warning( + mountImage && mountImage.rootNodeID && mountImage.tag, + 'Mount image returned does not have required data' + ); + ReactIOSTagHandles.associateRootNodeIDWithMountedNodeHandle( + childID, + childTag + ); + createdTags[i] = mountImage.tag; + } + RKUIManager + .manageChildren(containerTag, null, null, createdTags, indexes, null); + } + }, + + + /** + * Beware, this function has side effect to store this.previousFlattenedStyle! + * + * @param {!object} prevProps Previous properties + * @param {!object} nextProps Next properties + * @param {!object} validAttributes Set of valid attributes and how they + * should be diffed + */ + computeUpdatedProperties: function(prevProps, nextProps, validAttributes) { + if (__DEV__) { + for (var key in nextProps) { + if (nextProps.hasOwnProperty(key) && + nextProps[key] && + validAttributes[key]) { + deepFreezeAndThrowOnMutationInDev(nextProps[key]); + } + } + } + + var updatePayload = diffRawProperties( + null, // updatePayload + prevProps, + nextProps, + validAttributes + ); + + // The style property is a deeply nested element which includes numbers + // to represent static objects. Most of the time, it doesn't change across + // renders, so it's faster to spend the time checking if it is different + // before actually doing the expensive flattening operation in order to + // compute the diff. + if (styleDiffer(nextProps.style, prevProps.style)) { + var nextFlattenedStyle = flattenStyle(nextProps.style); + updatePayload = diffRawProperties( + updatePayload, + this.previousFlattenedStyle, + nextFlattenedStyle, + ReactIOSStyleAttributes + ); + this.previousFlattenedStyle = nextFlattenedStyle; + } + + return updatePayload; + }, + + + /** + * Updates the component's currently mounted representation. + * + * @param {ReactReconcileTransaction} transaction + * @param {object} prevDescriptor + * @internal + */ + updateComponent: function(transaction, prevDescriptor) { + ReactComponent.Mixin.updateComponent.call( + this, + transaction, + prevDescriptor + ); + var nextDescriptor = this._currentElement; + + var updatePayload = this.computeUpdatedProperties( + prevDescriptor.props, + nextDescriptor.props, + this.viewConfig.validAttributes + ); + + if (updatePayload) { + RKUIManager.updateView( + ReactIOSTagHandles.mostRecentMountedNodeHandleForRootNodeID(this._rootNodeID), + this.viewConfig.uiViewClassName, + updatePayload + ); + } + + this._reconcileListenersUponUpdate( + prevDescriptor.props, + nextDescriptor.props + ); + this.updateChildren(this.props.children, transaction); + }, + + /** + * @param {object} initialProps Native component props. + */ + _registerListenersUponCreation: function(initialProps) { + for (var key in initialProps) { + // NOTE: The check for `!props[key]`, is only possible because this method + // registers listeners the *first* time a component is created. + if (registrationNames[key] && initialProps[key]) { + var listener = initialProps[key]; + putListener(this._rootNodeID, key, listener); + } + } + }, + + /** + * Reconciles event listeners, adding or removing if necessary. + * @param {object} prevProps Native component props including events. + * @param {object} nextProps Next native component props including events. + */ + _reconcileListenersUponUpdate: function(prevProps, nextProps) { + for (var key in nextProps) { + if (registrationNames[key] && (nextProps[key] != prevProps[key])) { + putListener(this._rootNodeID, key, nextProps[key]); + } + } + }, + + /** + * @param {string} rootID Root ID of this subtree. + * @param {Transaction} transaction For creating/updating. + * @return {string} Unique iOS view tag. + */ + mountComponent: function(rootID, transaction, mountDepth) { + ReactComponent.Mixin.mountComponent.call( + this, + rootID, + transaction, + mountDepth + ); + var tag = ReactIOSTagHandles.allocateTag(); + + this.previousFlattenedStyle = {}; + var updatePayload = this.computeUpdatedProperties( + {}, // previous props + this.props, // next props + this.viewConfig.validAttributes + ); + RKUIManager.createView(tag, this.viewConfig.uiViewClassName, updatePayload); + + this._registerListenersUponCreation(this.props); + this.initializeChildren(this.props.children, tag, transaction); + return { + rootNodeID: rootID, + tag: tag + }; + } +}; + +/** + * Order of mixins is important. ReactIOSNativeComponent overrides methods in + * ReactMultiChild. + */ +Object.assign( + ReactIOSNativeComponent.prototype, + ReactComponent.Mixin, + ReactMultiChild.Mixin, + ReactIOSNativeComponent.Mixin, + NativeMethodsMixin, + ReactIOSComponentMixin +); + +module.exports = ReactIOSNativeComponent; diff --git a/Libraries/ReactIOS/ReactIOSReconcileTransaction.js b/Libraries/ReactIOS/ReactIOSReconcileTransaction.js new file mode 100644 index 0000000000..0c23d00e15 --- /dev/null +++ b/Libraries/ReactIOS/ReactIOSReconcileTransaction.js @@ -0,0 +1,99 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ReactIOSReconcileTransaction + * @typechecks static-only + */ + +"use strict"; + +var CallbackQueue = require('CallbackQueue'); +var PooledClass = require('PooledClass'); +var Transaction = require('Transaction'); + +/** + * Provides a `CallbackQueue` queue for collecting `onDOMReady` callbacks during + * the performing of the transaction. + */ +var ON_DOM_READY_QUEUEING = { + /** + * Initializes the internal `onDOMReady` queue. + */ + initialize: function() { + this.reactMountReady.reset(); + }, + + /** + * After DOM is flushed, invoke all registered `onDOMReady` callbacks. + */ + close: function() { + this.reactMountReady.notifyAll(); + } +}; + +/** + * Executed within the scope of the `Transaction` instance. Consider these as + * being member methods, but with an implied ordering while being isolated from + * each other. + */ +var TRANSACTION_WRAPPERS = [ON_DOM_READY_QUEUEING]; + +/** + * Currently: + * - The order that these are listed in the transaction is critical: + * - Suppresses events. + * - Restores selection range. + * + * Future: + * - Restore document/overflow scroll positions that were unintentionally + * modified via DOM insertions above the top viewport boundary. + * - Implement/integrate with customized constraint based layout system and keep + * track of which dimensions must be remeasured. + * + * @class ReactIOSReconcileTransaction + */ +function ReactIOSReconcileTransaction() { + this.reinitializeTransaction(); + this.reactMountReady = CallbackQueue.getPooled(null); +} + +var Mixin = { + /** + * @see Transaction + * @abstract + * @final + * @return {array} List of operation wrap proceedures. + * TODO: convert to array + */ + getTransactionWrappers: function() { + return TRANSACTION_WRAPPERS; + }, + + /** + * @return {object} The queue to collect `onDOMReady` callbacks with. + * TODO: convert to ReactMountReady + */ + getReactMountReady: function() { + return this.reactMountReady; + }, + + /** + * `PooledClass` looks for this, and will invoke this before allowing this + * instance to be resused. + */ + destructor: function() { + CallbackQueue.release(this.reactMountReady); + this.reactMountReady = null; + } +}; + +Object.assign( + ReactIOSReconcileTransaction.prototype, + Transaction.Mixin, + ReactIOSReconcileTransaction, + Mixin +); + +PooledClass.addPoolingTo(ReactIOSReconcileTransaction); + +module.exports = ReactIOSReconcileTransaction; diff --git a/Libraries/ReactIOS/ReactIOSStyleAttributes.js b/Libraries/ReactIOS/ReactIOSStyleAttributes.js new file mode 100644 index 0000000000..9c9a220baa --- /dev/null +++ b/Libraries/ReactIOS/ReactIOSStyleAttributes.js @@ -0,0 +1,25 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ReactIOSStyleAttributes + */ + +"use strict"; + +var TextStylePropTypes = require('TextStylePropTypes'); +var ViewStylePropTypes = require('ViewStylePropTypes'); + +var deepDiffer = require('deepDiffer'); +var keyMirror = require('keyMirror'); +var matricesDiffer = require('matricesDiffer'); +var merge = require('merge'); + +var ReactIOSStyleAttributes = merge( + keyMirror(ViewStylePropTypes), + keyMirror(TextStylePropTypes) +); + +ReactIOSStyleAttributes.transformMatrix = { diff: matricesDiffer }; +ReactIOSStyleAttributes.shadowOffset = { diff: deepDiffer }; + +module.exports = ReactIOSStyleAttributes; diff --git a/Libraries/ReactIOS/ReactIOSTagHandles.js b/Libraries/ReactIOS/ReactIOSTagHandles.js new file mode 100644 index 0000000000..a22b45ff63 --- /dev/null +++ b/Libraries/ReactIOS/ReactIOSTagHandles.js @@ -0,0 +1,88 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ReactIOSTagHandles + */ +'use strict'; + +var invariant = require('invariant'); +var warning = require('warning'); + +/** + * Keeps track of allocating and associating native "tags" which are numeric, + * unique view IDs. All the native tags are negative numbers, to avoid + * collisions, but in the JS we keep track of them as positive integers to store + * them effectively in Arrays. So we must refer to them as "inverses" of the + * native tags (that are * normally negative). + * + * It *must* be the case that every `rootNodeID` always maps to the exact same + * `tag` forever. The easiest way to accomplish this is to never delete + * anything from this table. + * Why: Because `dangerouslyReplaceNodeWithMarkupByID` relies on being able to + * unmount a component with a `rootNodeID`, then mount a new one in its place, + */ +var INITIAL_TAG_COUNT = 1; +var ReactIOSTagHandles = { + tagsStartAt: INITIAL_TAG_COUNT, + tagCount: INITIAL_TAG_COUNT, + + allocateTag: function() { + // Skip over root IDs as those are reserved for native + while (this.reactTagIsNativeTopRootID(ReactIOSTagHandles.tagCount)) { + ReactIOSTagHandles.tagCount++; + } + var tag = ReactIOSTagHandles.tagCount; + ReactIOSTagHandles.tagCount++; + return tag; + }, + + /** + * This associates the *last* observed *native* mounting between `rootNodeID` + * and some `tag`. This association doesn't imply that `rootNodeID` is still + * natively mounted as `tag`. The only reason why we don't clear the + * association when the `rootNodeID` is unmounted, is that we don't have a + * convenient time to disassociate them (otherwise we would). + * `unmountComponent` isn't the correct time because that doesn't imply that + * the native node has been natively unmounted. + */ + associateRootNodeIDWithMountedNodeHandle: function(rootNodeID, tag) { + warning(rootNodeID && tag, 'Root node or tag is null when associating'); + ReactIOSTagHandles.tagToRootNodeID[tag] = rootNodeID; + ReactIOSTagHandles.rootNodeIDToTag[rootNodeID] = tag; + }, + + allocateRootNodeIDForTag: function(tag) { + invariant( + this.reactTagIsNativeTopRootID(tag), + 'Expect a native root tag, instead got ', tag + ); + return '.r[' + tag + ']{TOP_LEVEL}'; + }, + + reactTagIsNativeTopRootID: function(reactTag) { + // We reserve all tags that are 1 mod 10 for native root views + return reactTag % 10 === 1; + }, + + /** + * Returns the native `nodeHandle` (`tag`) that was most recently *natively* + * mounted at the `rootNodeID`. Just because a React component has been + * mounted, that doesn't mean that its native node has been mounted. The + * native node is mounted when we actually make the call to insert the + * `nodeHandle` (`tag`) into the native hierarchy. + * + * @param {string} rootNodeID Root node ID to find most recently mounted tag + * for. Again, this doesn't imply that it is still currently mounted. + * @return {number} Tag ID of native view for most recent mounting of + * `rootNodeID`. + */ + mostRecentMountedNodeHandleForRootNodeID: function(rootNodeID) { + return ReactIOSTagHandles.rootNodeIDToTag[rootNodeID]; + }, + + tagToRootNodeID: [], + + rootNodeIDToTag: {} +}; + +module.exports = ReactIOSTagHandles; diff --git a/Libraries/ReactIOS/ReactIOSViewAttributes.js b/Libraries/ReactIOS/ReactIOSViewAttributes.js new file mode 100644 index 0000000000..df5dfb2499 --- /dev/null +++ b/Libraries/ReactIOS/ReactIOSViewAttributes.js @@ -0,0 +1,31 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ReactIOSViewAttributes + */ + +"use strict"; + +var merge = require('merge'); + +var ReactIOSViewAttributes = {}; + +ReactIOSViewAttributes.UIView = { + pointerEvents: true, + accessible: true, + accessibilityLabel: true, + testID: true, +}; + +ReactIOSViewAttributes.RKView = merge( + ReactIOSViewAttributes.UIView, { + + // This is a special performance property exposed by RKView and useful for + // scrolling content when there are many subviews, most of which are offscreen. + // For this property to be effective, it must be applied to a view that contains + // many subviews that extend outside its bound. The subviews must also have + // overflow: hidden, as should the containing view (or one of its superviews). + removeClippedSubviews: true +}); + +module.exports = ReactIOSViewAttributes; diff --git a/Libraries/ReactIOS/ReactTextComponent.js b/Libraries/ReactIOS/ReactTextComponent.js new file mode 100644 index 0000000000..7ecbed18aa --- /dev/null +++ b/Libraries/ReactIOS/ReactTextComponent.js @@ -0,0 +1,30 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @providesModule ReactTextComponent + * @typechecks static-only + */ + +"use strict"; + +var InjectedTextComponent = null; +var ReactTextComponent = function() { + return InjectedTextComponent.apply(this, arguments); +}; +ReactTextComponent.inject = function(textComponent) { + InjectedTextComponent = textComponent; +}; + +module.exports = ReactTextComponent; diff --git a/Libraries/ReactIOS/createReactIOSNativeComponentClass.js b/Libraries/ReactIOS/createReactIOSNativeComponentClass.js new file mode 100644 index 0000000000..8df2f510bb --- /dev/null +++ b/Libraries/ReactIOS/createReactIOSNativeComponentClass.js @@ -0,0 +1,29 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule createReactIOSNativeComponentClass + */ + +"use strict"; + +var ReactElement = require('ReactElement'); +var ReactLegacyElement = require('ReactLegacyElement'); +var ReactIOSNativeComponent = require('ReactIOSNativeComponent'); + +/** + * @param {string} config iOS View configuration. + * @private + */ +var createReactIOSNativeComponentClass = function(viewConfig) { + var Constructor = function(props) { + }; + Constructor.displayName = viewConfig.uiViewClassName; + Constructor.prototype = new ReactIOSNativeComponent(viewConfig); + Constructor.prototype.constructor = Constructor; + + return ReactLegacyElement.wrapFactory( + ReactElement.createFactory(Constructor) + ); +}; + +module.exports = createReactIOSNativeComponentClass; diff --git a/Libraries/ReactIOS/diffRawProperties.js b/Libraries/ReactIOS/diffRawProperties.js new file mode 100644 index 0000000000..3f1e9a758d --- /dev/null +++ b/Libraries/ReactIOS/diffRawProperties.js @@ -0,0 +1,85 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule diffRawProperties + */ +'use strict'; + +/** + * diffRawProperties takes two sets of props and a set of valid attributes + * and write to updatePayload the values that changed or were deleted + * + * @param {?object} updatePayload Overriden with the props that changed. + * @param {!object} prevProps Previous properties to diff against current + * properties. These properties are as supplied to component construction. + * @param {!object} prevProps Next "current" properties to diff against + * previous. These properties are as supplied to component construction. + * @return {?object} + */ +function diffRawProperties(updatePayload, prevProps, nextProps, validAttributes) { + var validAttributeConfig; + var nextProp; + var prevProp; + var isScalar; + var shouldUpdate; + + for (var propKey in nextProps) { + validAttributeConfig = validAttributes[propKey]; + if (!validAttributeConfig) { + continue; // not a valid native prop + } + prevProp = prevProps && prevProps[propKey]; + nextProp = nextProps[propKey]; + if (prevProp !== nextProp) { + // If you want a property's diff to be detected, you must configure it + // to be so - *or* it must be a scalar property. For now, we'll allow + // creation with any attribute that is not scalar, but we should + // eventually even reject those unless they are properly configured. + isScalar = typeof nextProp !== 'object' || nextProp === null; + shouldUpdate = isScalar || + !prevProp || + validAttributeConfig.diff && + validAttributeConfig.diff(prevProp, nextProp); + + if (shouldUpdate) { + updatePayload = updatePayload || {}; + updatePayload[propKey] = nextProp; + } + } + } + + // Also iterate through all the previous props to catch any that have been + // removed and make sure native gets the signal so it can reset them to the + // default. + for (var propKey in prevProps) { + validAttributeConfig = validAttributes[propKey]; + if (!validAttributeConfig) { + continue; // not a valid native prop + } + if (updatePayload && updatePayload[propKey] !== undefined) { + continue; // Prop already specified + } + prevProp = prevProps[propKey]; + nextProp = nextProps && nextProps[propKey]; + if (prevProp !== nextProp) { + if (nextProp === undefined) { + nextProp = null; // null is a sentinel we explicitly send to native + } + // If you want a property's diff to be detected, you must configure it + // to be so - *or* it must be a scalar property. For now, we'll allow + // creation with any attribute that is not scalar, but we should + // eventually even reject those unless they are properly configured. + isScalar = typeof nextProp !== 'object' || nextProp === null; + shouldUpdate = isScalar && prevProp !== nextProp || + validAttributeConfig.diff && + validAttributeConfig.diff(prevProp, nextProp); + if (shouldUpdate) { + updatePayload = updatePayload || {}; + updatePayload[propKey] = nextProp; + } + } + } + return updatePayload; +} + +module.exports = diffRawProperties; diff --git a/Libraries/ReactIOS/nativePropType.js b/Libraries/ReactIOS/nativePropType.js new file mode 100644 index 0000000000..d61c7f1914 --- /dev/null +++ b/Libraries/ReactIOS/nativePropType.js @@ -0,0 +1,18 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule nativePropType + */ +'use strict' + +/** + * A simple wrapper for prop types to mark them as native, which will allow them + * to be passed over the bridge to be applied to the native component if + * processed by `validAttributesFromPropTypes`. + */ +function nativePropType(propType) { + propType.isNative = true; + return propType; +} + +module.exports = nativePropType; diff --git a/Libraries/ReactIOS/renderApplication.ios.js b/Libraries/ReactIOS/renderApplication.ios.js new file mode 100644 index 0000000000..05d694c093 --- /dev/null +++ b/Libraries/ReactIOS/renderApplication.ios.js @@ -0,0 +1,20 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule renderApplication + */ +'use strict'; + +var React = require('React'); + +var invariant = require('invariant'); + +function renderApplication(RootComponent, initialProps, rootTag) { + invariant( + rootTag, + 'Expect to have a valid rootTag, instead got ', rootTag + ); + React.render(, rootTag); +} + +module.exports = renderApplication; diff --git a/Libraries/StyleSheet/ArrayOfPropType.js b/Libraries/StyleSheet/ArrayOfPropType.js new file mode 100644 index 0000000000..89d224d8b0 --- /dev/null +++ b/Libraries/StyleSheet/ArrayOfPropType.js @@ -0,0 +1,18 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ArrayOfPropType + */ +'use strict' + +var ReactPropTypes = require('ReactPropTypes'); + +var deepDiffer = require('deepDiffer'); + +var ArrayOfPropType = (type, differ) => { + var checker = ReactPropTypes.arrayOf(type); + checker.differ = differ ? differ : deepDiffer; + return checker; +}; + +module.exports = ArrayOfPropType; diff --git a/Libraries/StyleSheet/EdgeInsetsPropType.js b/Libraries/StyleSheet/EdgeInsetsPropType.js new file mode 100644 index 0000000000..dcf38de79c --- /dev/null +++ b/Libraries/StyleSheet/EdgeInsetsPropType.js @@ -0,0 +1,22 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule EdgeInsetsPropType + */ +'use strict' + +var PropTypes = require('ReactPropTypes'); + +var createStrictShapeTypeChecker = require('createStrictShapeTypeChecker'); +var insetsDiffer = require('insetsDiffer'); + +var EdgeInsetsPropType = createStrictShapeTypeChecker({ + top: PropTypes.number, + left: PropTypes.number, + bottom: PropTypes.number, + right: PropTypes.number, +}); + +EdgeInsetsPropType.differ = insetsDiffer; + +module.exports = EdgeInsetsPropType; diff --git a/Libraries/StyleSheet/LayoutPropTypes.js b/Libraries/StyleSheet/LayoutPropTypes.js new file mode 100644 index 0000000000..1e88df5818 --- /dev/null +++ b/Libraries/StyleSheet/LayoutPropTypes.js @@ -0,0 +1,92 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule LayoutPropTypes + */ +'use strict'; + +var ReactPropTypes = require('ReactPropTypes'); + +/** + * These properties are a subset of our styles that are consumed by the layout + * algorithm and affect the positioning and sizing of views. + */ + +var LayoutPropTypes = { + width: ReactPropTypes.number, + height: ReactPropTypes.number, + top: ReactPropTypes.number, + left: ReactPropTypes.number, + right: ReactPropTypes.number, + bottom: ReactPropTypes.number, + margin: ReactPropTypes.number, + marginVertical: ReactPropTypes.number, + marginHorizontal: ReactPropTypes.number, + marginTop: ReactPropTypes.number, + marginBottom: ReactPropTypes.number, + marginLeft: ReactPropTypes.number, + marginRight: ReactPropTypes.number, + padding: ReactPropTypes.number, + paddingVertical: ReactPropTypes.number, + paddingHorizontal: ReactPropTypes.number, + paddingTop: ReactPropTypes.number, + paddingBottom: ReactPropTypes.number, + paddingLeft: ReactPropTypes.number, + paddingRight: ReactPropTypes.number, + borderWidth: ReactPropTypes.number, + borderTopWidth: ReactPropTypes.number, + borderRightWidth: ReactPropTypes.number, + borderBottomWidth: ReactPropTypes.number, + borderLeftWidth: ReactPropTypes.number, + + position: ReactPropTypes.oneOf([ + 'absolute', + 'relative' + ]), + + // https://developer.mozilla.org/en-US/docs/Web/CSS/flex-direction + flexDirection: ReactPropTypes.oneOf([ + 'row', + 'column' + ]), + + // https://developer.mozilla.org/en-US/docs/Web/CSS/flex-wrap + flexWrap: ReactPropTypes.oneOf([ + 'wrap', + 'nowrap' + ]), + + // How to align children in the main direction + // https://developer.mozilla.org/en-US/docs/Web/CSS/justify-content + justifyContent: ReactPropTypes.oneOf([ + 'flex-start', + 'flex-end', + 'center', + 'space-between', + 'space-around' + ]), + + // How to align children in the cross direction + // https://developer.mozilla.org/en-US/docs/Web/CSS/align-items + alignItems: ReactPropTypes.oneOf([ + 'flex-start', + 'flex-end', + 'center', + 'stretch' + ]), + + // How to align the element in the cross direction + // https://developer.mozilla.org/en-US/docs/Web/CSS/align-items + alignSelf: ReactPropTypes.oneOf([ + 'auto', + 'flex-start', + 'flex-end', + 'center', + 'stretch' + ]), + + // https://developer.mozilla.org/en-US/docs/Web/CSS/flex + flex: ReactPropTypes.number, +}; + +module.exports = LayoutPropTypes; diff --git a/Libraries/StyleSheet/PointPropType.js b/Libraries/StyleSheet/PointPropType.js new file mode 100644 index 0000000000..b281b96e0b --- /dev/null +++ b/Libraries/StyleSheet/PointPropType.js @@ -0,0 +1,20 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule PointPropType + */ +'use strict' + +var PropTypes = require('ReactPropTypes'); + +var createStrictShapeTypeChecker = require('createStrictShapeTypeChecker'); +var pointsDiffer = require('pointsDiffer'); + +var PointPropType = createStrictShapeTypeChecker({ + x: PropTypes.number, + y: PropTypes.number, +}); + +PointPropType.differ = pointsDiffer; + +module.exports = PointPropType; diff --git a/Libraries/StyleSheet/StyleSheet.js b/Libraries/StyleSheet/StyleSheet.js new file mode 100644 index 0000000000..cca0f87dac --- /dev/null +++ b/Libraries/StyleSheet/StyleSheet.js @@ -0,0 +1,120 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule StyleSheet + */ +'use strict'; + +var ImageStylePropTypes = require('ImageStylePropTypes'); +var ReactPropTypeLocations = require('ReactPropTypeLocations'); +var StyleSheetRegistry = require('StyleSheetRegistry'); +var TextStylePropTypes = require('TextStylePropTypes'); +var ViewStylePropTypes = require('ViewStylePropTypes'); + +var invariant = require('invariant'); + +/** + * A StyleSheet is an abstraction similar to CSS StyleSheets + * + * Create a new StyleSheet: + * + * var styles = StyleSheet.create({ + * container: { + * borderRadius: 4, + * borderWidth: 0.5, + * borderColor: '#d6d7da', + * }, + * title: { + * fontSize: 19, + * fontWeight: 'bold', + * }, + * activeTitle: { + * color: 'red', + * }, + * }) + * + * Use a StyleSheet: + * + * + * + * + * + * Code quality: + * - By moving styles away from the render function, you're making the code + * code easier to understand. + * - Naming the styles is a good way to add meaning to the low level components + * in the render function. + * + * Performance: + * - Making a stylesheet from a style object makes it possible to refer to it + * by ID instead of creating a new style object every time. + * - It also allows to send the style only once through the bridge. All + * subsequent uses are going to refer an id (not implemented yet). + */ +class StyleSheet { + static create(obj) { + var result = {}; + for (var key in obj) { + StyleSheet.validateStyle(key, obj); + result[key] = StyleSheetRegistry.registerStyle(obj[key]); + } + return result; + } + + static validateStyleProp(prop, style, caller) { + if (!__DEV__) { + return; + } + if (allStylePropTypes[prop] === undefined) { + var message1 = '"' + prop + '" is not a valid style property.'; + var message2 = '\nValid style props: ' + + JSON.stringify(Object.keys(allStylePropTypes), null, ' '); + styleError(message1, style, caller, message2); + } + var error = allStylePropTypes[prop]( + style, + prop, + caller, + ReactPropTypeLocations.prop + ); + if (error) { + styleError(error.message, style, caller); + } + } + + static validateStyle(name, styles) { + if (!__DEV__) { + return; + } + for (var prop in styles[name]) { + StyleSheet.validateStyleProp(prop, styles[name], 'StyleSheet ' + name); + } + } + + static addValidStylePropTypes(stylePropTypes) { + for (var key in stylePropTypes) { + invariant( + allStylePropTypes[key] === undefined || + allStylePropTypes[key] === stylePropTypes[key], + 'Attemped to redefine existing style prop type "' + key + '".' + ); + allStylePropTypes[key] = stylePropTypes[key]; + } + } +} + +var styleError = function(message1, style, caller, message2) { + invariant( + false, + message1 + '\n' + (caller || '<>') + ': ' + + JSON.stringify(style, null, ' ') + (message2 || '') + ); +}; + +var allStylePropTypes = {}; + +StyleSheet.addValidStylePropTypes(ImageStylePropTypes); +StyleSheet.addValidStylePropTypes(TextStylePropTypes); +StyleSheet.addValidStylePropTypes(ViewStylePropTypes); + +module.exports = StyleSheet; diff --git a/Libraries/StyleSheet/StyleSheetPropType.js b/Libraries/StyleSheet/StyleSheetPropType.js new file mode 100644 index 0000000000..f12245416d --- /dev/null +++ b/Libraries/StyleSheet/StyleSheetPropType.js @@ -0,0 +1,24 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule StyleSheetPropType + */ +'use strict'; + +var createStrictShapeTypeChecker = require('createStrictShapeTypeChecker'); +var flattenStyle = require('flattenStyle'); + +function StyleSheetPropType(shape) { + var shapePropType = createStrictShapeTypeChecker(shape); + return function(props, propName, componentName, location) { + var newProps = props; + if (props[propName]) { + // Just make a dummy prop object with only the flattened style + newProps = {}; + newProps[propName] = flattenStyle(props[propName]); + } + return shapePropType(newProps, propName, componentName, location); + }; +} + +module.exports = StyleSheetPropType; diff --git a/Libraries/StyleSheet/StyleSheetRegistry.js b/Libraries/StyleSheet/StyleSheetRegistry.js new file mode 100644 index 0000000000..d165f44ae6 --- /dev/null +++ b/Libraries/StyleSheet/StyleSheetRegistry.js @@ -0,0 +1,38 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule StyleSheetRegistry + */ +'use strict'; + +var styles = {}; +var uniqueID = 1; +var emptyStyle = {}; + +class StyleSheetRegistry { + static registerStyle(style) { + var id = ++uniqueID; + if (__DEV__) { + Object.freeze(style); + } + styles[id] = style; + return id; + } + + static getStyleByID(id) { + if (!id) { + // Used in the style={[condition && id]} pattern, + // we want it to be a no-op when the value is false or null + return emptyStyle; + } + + var style = styles[id]; + if (!style) { + console.warn('Invalid style with id `' + id + '`. Skipping ...'); + return emptyStyle; + } + return style; + } +} + +module.exports = StyleSheetRegistry; diff --git a/Libraries/StyleSheet/flattenStyle.js b/Libraries/StyleSheet/flattenStyle.js new file mode 100644 index 0000000000..44b6a26d57 --- /dev/null +++ b/Libraries/StyleSheet/flattenStyle.js @@ -0,0 +1,37 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule flattenStyle + */ +'use strict'; + +var StyleSheetRegistry = require('StyleSheetRegistry'); +var mergeIntoFast = require('mergeIntoFast'); + +function getStyle(style) { + if (typeof style === 'number') { + return StyleSheetRegistry.getStyleByID(style); + } + return style; +} + +function flattenStyle(style) { + if (!style) { + return undefined; + } + + if (!Array.isArray(style)) { + return getStyle(style); + } + + var result = {}; + for (var i = 0; i < style.length; ++i) { + var computedStyle = flattenStyle(style[i]); + if (computedStyle) { + mergeIntoFast(result, computedStyle); + } + } + return result; +} + +module.exports = flattenStyle; diff --git a/Libraries/StyleSheet/styleDiffer.js b/Libraries/StyleSheet/styleDiffer.js new file mode 100644 index 0000000000..e73d4c6874 --- /dev/null +++ b/Libraries/StyleSheet/styleDiffer.js @@ -0,0 +1,55 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule styleDiffer + */ +'use strict'; + +var deepDiffer = require('deepDiffer'); + +function styleDiffer(a, b) { + return !styleEqual(a, b); +} + +function styleEqual(a, b) { + if (!a) { + return !b; + } + if (!b) { + return !a; + } + if (typeof a !== typeof b) { + return false; + } + if (typeof a === 'number') { + return a === b; + } + + if (Array.isArray(a)) { + if (!Array.isArray(b) || a.length !== b.length) { + return false; + } + for (var i = 0; i < a.length; ++i) { + if (!styleEqual(a[i], b[i])) { + return false; + } + } + return true; + } + + for (var key in a) { + if (deepDiffer(a[key], b[key])) { + return false; + } + } + + for (var key in b) { + if (!a.hasOwnProperty(key)) { + return false; + } + } + + return true; +} + +module.exports = styleDiffer; diff --git a/Libraries/Utilities/Dimensions.js b/Libraries/Utilities/Dimensions.js new file mode 100644 index 0000000000..81621fd61f --- /dev/null +++ b/Libraries/Utilities/Dimensions.js @@ -0,0 +1,45 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule Dimensions + */ +'use strict'; + +var NativeModules = require('NativeModules'); + +var invariant = require('invariant'); +var mergeInto = require('mergeInto'); + +var dimensions = NativeModules.RKUIManager.Dimensions; + +class Dimensions { + /** + * This should only be called from native code. + * + * @param {object} dims Simple string-keyed object of dimensions to set + */ + static set(dims) { + mergeInto(dimensions, dims); + return true; + } + + /** + * Initial dimensions are set before `runApplication` is called so they should + * be available before any other require's are run, but may be updated later. + * + * Note: Although dimensions are available immediately, they may change (e.g + * due to device rotation) so any rendering logic or styles that depend on + * these constants should try to call this function on every render, rather + * than caching the value (for example, using inline styles rather than + * setting a value in a `StyleSheet`). + * + * @param {string} dim Name of dimension as defined when calling `set`. + * @returns {Object?} Value for the dimension. + */ + static get(dim) { + invariant(dimensions[dim], 'No dimension set for key ' + dim); + return dimensions[dim]; + } +} + +module.exports = Dimensions; diff --git a/Libraries/Utilities/ErrorUtils.js b/Libraries/Utilities/ErrorUtils.js new file mode 100644 index 0000000000..3b8ffe4858 --- /dev/null +++ b/Libraries/Utilities/ErrorUtils.js @@ -0,0 +1,21 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule ErrorUtils + */ + +var GLOBAL = this; + +/** + * The particular require runtime that we are using looks for a global + * `ErrorUtils` object and if it exists, then it requires modules with the + * error handler specified via ErrorUtils.setGlobalHandler by calling the + * require function with applyWithGuard. Since the require module is loaded + * before any of the modules, this ErrorUtils must be defined (and the handler + * set) globally before requiring anything. + * + * However, we still want to treat ErrorUtils as a module so that other modules + * that use it aren't just using a global variable, so simply export the global + * variable here. ErrorUtils is original defined in a file named error-guard.js. + */ +module.exports = GLOBAL.ErrorUtils; diff --git a/Libraries/Utilities/MessageQueue.js b/Libraries/Utilities/MessageQueue.js new file mode 100644 index 0000000000..c5cba05250 --- /dev/null +++ b/Libraries/Utilities/MessageQueue.js @@ -0,0 +1,476 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule MessageQueue + */ +'use strict'; +var ErrorUtils = require('ErrorUtils'); + +var invariant = require('invariant'); +var warning = require('warning'); + +var JSTimersExecution = require('JSTimersExecution'); + +var INTERNAL_ERROR = 'Error in MessageQueue implementation'; + +/** + * So as not to confuse static build system. + */ +var requireFunc = require; + +/** + * @param {Object!} module Module instance, must be loaded. + * @param {string} methodName Name of method in `module`. + * @param {array<*>} params Arguments to method. + * @returns {*} Return value of method invocation. + */ +var jsCall = function(module, methodName, params) { + return module[methodName].apply(module, params); +}; + +/** + * A utility for aggregating "work" to be done, and potentially transferring + * that work to another thread. Each instance of `MessageQueue` has the notion + * of a "target" thread - the thread that the work will be sent to. + * + * TODO: Long running callback results, and streaming callback results (ability + * for a callback to be invoked multiple times). + * + * @param {object} moduleNameToID Used to translate module/method names into + * efficient numeric IDs. + * @class MessageQueue + */ +var MessageQueue = function(remoteModulesConfig, localModulesConfig, customRequire) { + this._requireFunc = customRequire || requireFunc; + this._initBookeeping(); + this._initNamingMap(remoteModulesConfig, localModulesConfig); +}; + +// REQUEST: Parallell arrays: +var REQUEST_MODULE_IDS = 0; +var REQUEST_METHOD_IDS = 1; +var REQUEST_PARAMSS = 2; +// RESPONSE: Parallell arrays: +var RESPONSE_CBIDS = 3; +var RESPONSE_RETURN_VALUES = 4; + +/** + * Utility to catch errors and prevent having to bind, or execute a bound + * function, while catching errors in a process and returning a resulting + * return value. This ensures that even if a process fails, we can still return + * *some* values (from `_flushedQueueUnguarded` for example). Glorified + * try/catch/finally that invokes the global `onerror`. + * + * @param {function} operation Function to execute, likely populates the + * message buffer. + * @param {Array<*>} operationArguments Arguments passed to `operation`. + * @param {function} getReturnValue Returns a return value - will be invoked + * even if the `operation` fails half way through completing its task. + * @return {object} Return value returned from `getReturnValue`. + */ +var guardReturn = function(operation, operationArguments, getReturnValue, context) { + if (operation) { + ErrorUtils.applyWithGuard(operation, context, operationArguments); + } + if (getReturnValue) { + return ErrorUtils.applyWithGuard(getReturnValue, context, null); + } + return null; +}; + +/** + * Bookkeeping logic for callbackIDs. We ensure that success and error + * callbacks are numerically adjacent. + * + * We could have also stored the association between success cbID and errorCBID + * in a map without relying on this adjacency, but the bookkeeping here avoids + * an additional two maps to associate in each direction, and avoids growing + * dictionaries (new fields). Instead, we compute pairs of callback IDs, by + * populating the `res` argument to `allocateCallbackIDs` (in conjunction with + * pooling). Behind this bookeeping API, we ensure that error and success + * callback IDs are always adjacent so that when one is invoked, we always know + * how to free the memory of the other. By using this API, it is impossible to + * create malformed callbackIDs that are not adjacent. + */ +var createBookkeeping = function() { + return { + /** + * Incrementing callback ID. Must start at 1 - otherwise converted null + * values which become zero are not distinguishable from a GUID of zero. + */ + GUID: 1, + errorCallbackIDForSuccessCallbackID: function(successID) { + return successID + 1; + }, + successCallbackIDForErrorCallbackID: function(errorID) { + return errorID - 1; + }, + allocateCallbackIDs: function(res) { + res.successCallbackID = this.GUID++; + res.errorCallbackID = this.GUID++; + }, + isSuccessCallback: function(id) { + return id % 2 === 1; + } + }; +}; + +var MessageQueueMixin = { + /** + * Creates an efficient wire protocol for communicating across a bridge. + * Avoids allocating strings. + * + * @param {object} remoteModulesConfig Configuration of modules and their + * methods. + */ + _initNamingMap: function(remoteModulesConfig, localModulesConfig) { + this._remoteModuleNameToModuleID = {}; + this._remoteModuleIDToModuleName = {}; // Reverse + + this._remoteModuleNameToMethodNameToID = {}; + this._remoteModuleNameToMethodIDToName = {}; // Reverse + + this._localModuleNameToModuleID = {}; + this._localModuleIDToModuleName = {}; // Reverse + + this._localModuleNameToMethodNameToID = {}; + this._localModuleNameToMethodIDToName = {}; // Reverse + + function fillMappings( + modulesConfig, + moduleNameToModuleID, + moduleIDToModuleName, + moduleNameToMethodNameToID, + moduleNameToMethodIDToName + ) { + for (var moduleName in modulesConfig) { + var moduleConfig = modulesConfig[moduleName]; + var moduleID = moduleConfig.moduleID; + moduleNameToModuleID[moduleName] = moduleID; + moduleIDToModuleName[moduleID] = moduleName; // Reverse + + moduleNameToMethodNameToID[moduleName] = {}; + moduleNameToMethodIDToName[moduleName] = {}; // Reverse + var methods = moduleConfig.methods; + for (var methodName in methods) { + var methodID = methods[methodName].methodID; + moduleNameToMethodNameToID[moduleName][methodName] = + methodID; + moduleNameToMethodIDToName[moduleName][methodID] = + methodName; // Reverse + } + } + } + fillMappings( + remoteModulesConfig, + this._remoteModuleNameToModuleID, + this._remoteModuleIDToModuleName, + this._remoteModuleNameToMethodNameToID, + this._remoteModuleNameToMethodIDToName + ); + + fillMappings( + localModulesConfig, + this._localModuleNameToModuleID, + this._localModuleIDToModuleName, + this._localModuleNameToMethodNameToID, + this._localModuleNameToMethodIDToName + ); + + }, + + _initBookeeping: function() { + this._POOLED_CBIDS = {errorCallbackID: null, successCallbackID: null}; + this._bookkeeping = createBookkeeping(); + + /** + * Stores callbacks so that we may simulate asynchronous return values from + * other threads. Remote invocations in other threads can pass return values + * back asynchronously to the requesting thread. + */ + this._threadLocalCallbacksByID = []; + this._threadLocalScopesByID = []; + + /** + * Memory efficient parallel arrays. Each index cuts through the three + * arrays and forms a remote invocation of methodName(params) whos return + * value will be reported back to the other thread by way of the + * corresponding id in cbIDs. Each entry (A-D in the graphic below), + * represents a work item of the following form: + * - moduleID: ID of module to invoke method from. + * - methodID: ID of method in module to invoke. + * - params: List of params to pass to method. + * - cbID: ID to respond back to originating thread with. + * + * TODO: We can make this even more efficient (memory) by creating a single + * array, that is always pushed `n` elements as a time. + */ + this._outgoingItems = [ + /*REQUEST_MODULE_IDS: */ [/* +-+ +-+ +-+ +-+ */], + /*REQUEST_METHOD_IDS: */ [/* |A| |B| |C| |D| */], + /*REQUEST_PARAMSS: */ [/* |-| |-| |-| |-| */], + + /*RESPONSE_CBIDS: */ [/* +-+ +-+ +-+ +-+ */], + /* |E| |F| |G| |H| */ + /*RESPONSE_RETURN_VALUES: */ [/* +-+ +-+ +-+ +-+ */] + ]; + + /** + * Used to allow returning the buffer, while at the same time clearing it in + * a memory efficient manner. + */ + this._outgoingItemsSwap = [[], [], [], [], []]; + }, + + invokeCallback: function(cbID, args) { + return guardReturn(this._invokeCallback, [cbID, args], null, this); + }, + + _invokeCallback: function(cbID, args) { + try { + var cb = this._threadLocalCallbacksByID[cbID]; + var scope = this._threadLocalScopesByID[cbID]; + warning( + cb, + 'Cannot find callback with CBID %s. Native module may have invoked ' + + 'both the success callback and the error callback.', + cbID + ); + cb.apply(scope, args); + } catch(ie_requires_catch) { + throw ie_requires_catch; + } finally { + // Clear out the memory regardless of success or failure. + this._freeResourcesForCallbackID(cbID); + } + }, + + invokeCallbackAndReturnFlushedQueue: function(cbID, args) { + if (this._enableLogging) { + this._loggedIncomingItems.push([new Date().getTime(), cbID, args]); + } + return guardReturn( + this._invokeCallback, + [cbID, args], + this._flushedQueueUnguarded, + this + ); + }, + + callFunction: function(moduleID, methodID, params) { + return guardReturn(this._callFunction, [moduleID, methodID, params], null, this); + }, + + _callFunction: function(moduleID, methodID, params) { + var moduleName = this._localModuleIDToModuleName[moduleID]; + + var methodName = this._localModuleNameToMethodIDToName[moduleName][methodID]; + var ret = jsCall(this._requireFunc(moduleName), methodName, params); + + return ret; + }, + + callFunctionReturnFlushedQueue: function(moduleID, methodID, params) { + if (this._enableLogging) { + this._loggedIncomingItems.push([new Date().getTime(), moduleID, methodID, params]); + } + return guardReturn( + this._callFunction, + [moduleID, methodID, params], + this._flushedQueueUnguarded, + this + ); + }, + + setLoggingEnabled: function(enabled) { + this._enableLogging = enabled; + this._loggedIncomingItems = []; + this._loggedOutgoingItems = [[], [], [], [], []]; + }, + + getLoggedIncomingItems: function() { + return this._loggedIncomingItems; + }, + + getLoggedOutgoingItems: function() { + return this._loggedOutgoingItems; + }, + + replayPreviousLog: function(previousLog) { + this._outgoingItems = previousLog; + }, + + /** + * Simple helpers for clearing the queues. This doesn't handle the fact that + * memory in the current buffer is leaked until the next frame or update - but + * that will typically be on the order of < 500ms. + */ + _swapAndReinitializeBuffer: function() { + // Outgoing requests + var currentOutgoingItems = this._outgoingItems; + var nextOutgoingItems = this._outgoingItemsSwap; + + nextOutgoingItems[REQUEST_MODULE_IDS].length = 0; + nextOutgoingItems[REQUEST_METHOD_IDS].length = 0; + nextOutgoingItems[REQUEST_PARAMSS].length = 0; + + // Outgoing responses + nextOutgoingItems[RESPONSE_CBIDS].length = 0; + nextOutgoingItems[RESPONSE_RETURN_VALUES].length = 0; + + this._outgoingItemsSwap = currentOutgoingItems; + this._outgoingItems = nextOutgoingItems; + }, + + /** + * @param {string} moduleID JS module name. + * @param {methodName} methodName Method in module to invoke. + * @param {array<*>?} params Array representing arguments to method. + * @param {string} cbID Unique ID to pass back in potential response. + */ + _pushRequestToOutgoingItems: function(moduleID, methodName, params) { + this._outgoingItems[REQUEST_MODULE_IDS].push(moduleID); + this._outgoingItems[REQUEST_METHOD_IDS].push(methodName); + this._outgoingItems[REQUEST_PARAMSS].push(params); + + if (this._enableLogging) { + this._loggedOutgoingItems[REQUEST_MODULE_IDS].push(moduleID); + this._loggedOutgoingItems[REQUEST_METHOD_IDS].push(methodName); + this._loggedOutgoingItems[REQUEST_PARAMSS].push(params); + } + }, + + /** + * @param {string} cbID Unique ID that other side of bridge has remembered. + * @param {*} returnValue Return value to pass to callback on other side of + * bridge. + */ + _pushResponseToOutgoingItems: function(cbID, returnValue) { + this._outgoingItems[RESPONSE_CBIDS].push(cbID); + this._outgoingItems[RESPONSE_RETURN_VALUES].push(returnValue); + }, + + _freeResourcesForCallbackID: function(cbID) { + var correspondingCBID = this._bookkeeping.isSuccessCallback(cbID) ? + this._bookkeeping.errorCallbackIDForSuccessCallbackID(cbID) : + this._bookkeeping.successCallbackIDForErrorCallbackID(cbID); + this._threadLocalCallbacksByID[cbID] = null; + this._threadLocalScopesByID[cbID] = null; + if (this._threadLocalCallbacksByID[correspondingCBID]) { + this._threadLocalCallbacksByID[correspondingCBID] = null; + this._threadLocalScopesByID[correspondingCBID] = null; + } + }, + + /** + * @param {Function} onFail Function to store in current thread for later + * lookup, when request fails. + * @param {Function} onSucc Function to store in current thread for later + * lookup, when request succeeds. + * @param {Object?=} scope Scope to invoke `cb` with. + * @param {Object?=} res Resulting callback ids. Use `this._POOLED_CBIDS`. + */ + _storeCallbacksInCurrentThread: function(onFail, onSucc, scope) { + invariant(onFail || onSucc, INTERNAL_ERROR); + this._bookkeeping.allocateCallbackIDs(this._POOLED_CBIDS); + var succCBID = this._POOLED_CBIDS.successCallbackID; + var errorCBID = this._POOLED_CBIDS.errorCallbackID; + this._threadLocalCallbacksByID[errorCBID] = onFail; + this._threadLocalCallbacksByID[succCBID] = onSucc; + this._threadLocalScopesByID[errorCBID] = scope; + this._threadLocalScopesByID[succCBID] = scope; + }, + + + /** + * IMPORTANT: There is possibly a timing issue with this form of flushing. We + * are currently not seeing any problems but the potential issue to look out + * for is: + * - While flushing this._outgoingItems contains the work for the other thread + * to perform. + * - To mitigate this, we never allow enqueueing messages if the queue is + * already reserved - as long as it is reserved, it could be in the midst of + * a flush. + * + * If this ever occurs we can easily eliminate the race condition. We can + * completely solve any ambiguity by sending messages such that we'll never + * try to reserve the queue when already reserved. Here's the pseudocode: + * + * var defensiveCopy = efficientDefensiveCopy(this._outgoingItems); + * this._swapAndReinitializeBuffer(); + */ + flushedQueue: function() { + return guardReturn(null, null, this._flushedQueueUnguarded, this); + }, + + _flushedQueueUnguarded: function() { + // Call the functions registred via setImmediate + JSTimersExecution.callImmediates(); + + var currentOutgoingItems = this._outgoingItems; + this._swapAndReinitializeBuffer(); + var ret = currentOutgoingItems[REQUEST_MODULE_IDS].length || + currentOutgoingItems[RESPONSE_RETURN_VALUES].length ? currentOutgoingItems : null; + + return ret; + }, + + callDeprecated: function(moduleName, methodName, params, cb, scope) { + invariant( + !cb || typeof cb === 'function', + 'Last argument (callback) must be function' + ); + // Store callback _before_ sending the request, just in case the MailBox + // returns the response in a blocking manner + if (cb) { + this._storeCallbacksInCurrentThread(null, cb, scope, this._POOLED_CBIDS); + params.push(this._POOLED_CBIDS.successCallbackID); + } + var moduleID = this._remoteModuleNameToModuleID[moduleName]; + if (moduleID === undefined || moduleID === null) { + throw new Error('Unrecognized module name:' + moduleName); + } + var methodID = this._remoteModuleNameToMethodNameToID[moduleName][methodName]; + if (methodID === undefined || moduleID === null) { + throw new Error('Unrecognized method name:' + methodName); + } + this._pushRequestToOutgoingItems(moduleID, methodID, params); + }, + + call: function(moduleName, methodName, params, onFail, onSucc, scope) { + invariant( + (!onFail || typeof onFail === 'function') && + (!onSucc || typeof onSucc === 'function'), + 'Callbacks must be functions' + ); + // Store callback _before_ sending the request, just in case the MailBox + // returns the response in a blocking manner. + if (onFail || onSucc) { + this._storeCallbacksInCurrentThread(onFail, onSucc, scope, this._POOLED_CBIDS); + params.push(this._POOLED_CBIDS.errorCallbackID); + params.push(this._POOLED_CBIDS.successCallbackID); + } + var moduleID = this._remoteModuleNameToModuleID[moduleName]; + if (moduleID === undefined || moduleID === null) { + throw new Error('Unrecognized module name:' + moduleName); + } + var methodID = this._remoteModuleNameToMethodNameToID[moduleName][methodName]; + if (methodID === undefined || moduleID === null) { + throw new Error('Unrecognized method name:' + methodName); + } + this._pushRequestToOutgoingItems(moduleID, methodID, params); + }, + __numPendingCallbacksOnlyUseMeInTestCases: function() { + var callbacks = this._threadLocalCallbacksByID; + var total = 0; + for (var i = 0; i < callbacks.length; i++) { + if (callbacks[i]) { + total++; + } + } + return total; + } +}; + +Object.assign(MessageQueue.prototype, MessageQueueMixin); +module.exports = MessageQueue; diff --git a/Libraries/Utilities/PixelRatio.js b/Libraries/Utilities/PixelRatio.js new file mode 100644 index 0000000000..0eeb074c68 --- /dev/null +++ b/Libraries/Utilities/PixelRatio.js @@ -0,0 +1,54 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule PixelRatio + */ +'use strict'; + +var Dimensions = require('Dimensions'); + +/** + * PixelRatio class gives access to the device pixel density. + * + * Some examples: + * - PixelRatio.get() === 2 + * - iPhone 4, 4S + * - iPhone 5, 5c, 5s + * - iPhone 6 + * + * - PixelRatio.get() === 3 + * - iPhone 6 plus + * + * There are a few use cases for using PixelRatio: + * + * == Displaying a line that's as thin as the device permits + * + * A width of 1 is actually pretty thick on an iPhone 4+, we can do one that's + * thinner using a width of 1 / PixelRatio.get(). It's a technique that works + * on all the devices independent of their pixel density. + * + * style={{ borderWidth: 1 / PixelRatio.get() }} + * + * == Fetching a correctly sized image + * + * You should get a higher resolution image if you are on a high pixel density + * device. A good rule of thumb is to multiply the size of the image you display + * by the pixel ratio. + * + * var image = getImage({ + * width: 200 * PixelRatio.get(), + * height: 100 * PixelRatio.get() + * }); + * + */ +class PixelRatio { + static get() { + return Dimensions.get('window').scale; + } + + static startDetecting() { + // no-op for iOS, but this is useful for other platforms + } +}; + +module.exports = PixelRatio; diff --git a/Libraries/Utilities/Platform.ios.js b/Libraries/Utilities/Platform.ios.js new file mode 100644 index 0000000000..ef61a47dee --- /dev/null +++ b/Libraries/Utilities/Platform.ios.js @@ -0,0 +1,11 @@ +/** +* @providesModule Platform +*/ + +'use strict'; + +var Platform = { + OS: 'ios', +}; + +module.exports = Platform; diff --git a/Libraries/Utilities/RCTLog.js b/Libraries/Utilities/RCTLog.js new file mode 100644 index 0000000000..ab648098a4 --- /dev/null +++ b/Libraries/Utilities/RCTLog.js @@ -0,0 +1,37 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule RCTLog + */ + /* globals nativeLoggingHook */ +'use strict'; + +var invariant = require('invariant'); + +var levelsMap = { + log: 'log', + info: 'info', + warn: 'warn', + error: 'error', + mustfix: 'error', +}; + +class RCTLog { + // level one of log, info, warn, error, mustfix + static logIfNoNativeHook() { + var args = Array.prototype.slice.call(arguments); + var level = args.shift(); + var logFn = levelsMap[level]; + invariant( + logFn, + 'Level "' + level + '" not one of ' + Object.keys(levelsMap) + ); + if (typeof nativeLoggingHook === 'undefined') { + // We already printed in xcode, so only log here if using a js debugger + console[logFn].apply(console, args); + } + return true; + } +} + +module.exports = RCTLog; diff --git a/Libraries/Utilities/RCTRenderingPerf.js b/Libraries/Utilities/RCTRenderingPerf.js new file mode 100644 index 0000000000..cdc44aaa6a --- /dev/null +++ b/Libraries/Utilities/RCTRenderingPerf.js @@ -0,0 +1,56 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule RCTRenderingPerf + */ +'use strict'; + +var ReactDefaultPerf = require('ReactDefaultPerf'); +var ReactPerf = require('ReactPerf'); + +var invariant = require('invariant'); + +var perfModules = []; +var enabled = false; + +var RCTRenderingPerf = { + // Once perf is enabled, it stays enabled + toggle: function() { + console.log('Render perfomance measurements enabled'); + enabled = true; + }, + + start: function() { + if (!enabled) { + return; + } + + ReactDefaultPerf.start(); + perfModules.forEach((module) => module.start()); + }, + + stop: function() { + if (!enabled) { + return; + } + + ReactDefaultPerf.stop(); + ReactDefaultPerf.printInclusive(); + ReactDefaultPerf.printWasted(); + perfModules.forEach((module) => module.stop()); + }, + + register: function(module) { + invariant( + typeof module.start === 'function', + 'Perf module should have start() function' + ); + invariant( + typeof module.stop === 'function', + 'Perf module should have stop() function' + ); + perfModules.push(module); + } +}; + +module.exports = RCTRenderingPerf; diff --git a/Libraries/Utilities/TimerMixin.js b/Libraries/Utilities/TimerMixin.js new file mode 100644 index 0000000000..eb2c286396 --- /dev/null +++ b/Libraries/Utilities/TimerMixin.js @@ -0,0 +1,97 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule TimerMixin + */ +'use strict'; + +/** + * Using bare setTimeout, setInterval, setImmediate and + * requestAnimationFrame calls is very dangerous because if you forget to cancel + * the request before the component is unmounted, you risk the callback throwing + * an exception. + * + * If you include TimerMixin, then you can replace your calls + * to `setTimeout(fn, 500)` + * with `this.setTimeout(fn, 500)` (just prepend `this.`) + * and everything will be properly cleaned up for you. + * + * Example: + * + * var Component = React.createClass({ + * mixins: [TimerMixin], + * componentDidMount: function() { + * this.setTimeout( + * () => { console.log('I do not leak!'); }, + * 500 + * ); + * } + * }); + */ + + var setter = function(setter, clearer, array) { + return function(callback, delta) { + var id = setter(() => { + clearer.call(this, id); + callback.apply(this, arguments); + }, delta); + + if (!this[array]) { + this[array] = [id]; + } else { + this[array].push(id); + } + return id; + }; + }; + + var clearer = function(clearer, array) { + return function(id) { + if (this[array]) { + var index = this[array].indexOf(id); + if (index !== -1) { + this[array].splice(index, 1); + } + } + clearer(id); + }; + }; + + var _timeouts = 'TimerMixin_timeouts'; + var _clearTimeout = clearer(clearTimeout, _timeouts); + var _setTimeout = setter(setTimeout, _clearTimeout, _timeouts); + + var _intervals = 'TimerMixin_intervals'; + var _clearInterval = clearer(clearInterval, _intervals); + var _setInterval = setter(setInterval, () => {/* noop */}, _intervals); + + var _immediates = 'TimerMixin_immediates'; + var _clearImmediate = clearer(clearImmediate, _immediates); + var _setImmediate = setter(setImmediate, _clearImmediate, _immediates); + + var _rafs = 'TimerMixin_rafs'; + var _cancelAnimationFrame = clearer(cancelAnimationFrame, _rafs); + var _requestAnimationFrame = setter(requestAnimationFrame, _cancelAnimationFrame, _rafs); + +var TimerMixin = { + componentWillUnmount: function() { + this[_timeouts] && this[_timeouts].forEach(this.clearTimeout); + this[_intervals] && this[_intervals].forEach(this.clearInterval); + this[_immediates] && this[_immediates].forEach(this.clearImmediate); + this[_rafs] && this[_rafs].forEach(this.cancelAnimationFrame); + }, + + setTimeout: _setTimeout, + clearTimeout: _clearTimeout, + + setInterval: _setInterval, + clearInterval: _clearInterval, + + setImmediate: _setImmediate, + clearImmediate: _clearImmediate, + + requestAnimationFrame: _requestAnimationFrame, + cancelAnimationFrame: _cancelAnimationFrame, +}; + +module.exports = TimerMixin; diff --git a/Libraries/Utilities/createStrictShapeTypeChecker.js b/Libraries/Utilities/createStrictShapeTypeChecker.js new file mode 100644 index 0000000000..daaacb9b42 --- /dev/null +++ b/Libraries/Utilities/createStrictShapeTypeChecker.js @@ -0,0 +1,64 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule createStrictShapeTypeChecker + */ +'use strict'; + +var ReactPropTypeLocationNames = require('ReactPropTypeLocationNames'); + +var invariant = require('invariant'); +var merge = require('merge'); + +function createStrictShapeTypeChecker(shapeTypes) { + function checkType(isRequired, props, propName, componentName, location) { + if (!props[propName]) { + if (isRequired) { + invariant( + false, + `Required object \`${propName}\` was not specified in `+ + `\`${componentName}\`.` + ); + } + return; + } + var propValue = props[propName]; + var propType = typeof propValue; + var locationName = ReactPropTypeLocationNames[location]; + if (propType !== 'object') { + invariant( + false, + `Invalid ${locationName} \`${propName}\` of type \`${propType}\` ` + + `supplied to \`${componentName}\`, expected \`object\`.` + ); + } + // We need to check all keys in case some are required but missing from + // props. + var allKeys = merge(props[propName], shapeTypes); + for (var key in allKeys) { + var checker = shapeTypes[key]; + if (!checker) { + invariant( + false, + `Invalid props.${propName} key \`${key}\` supplied to \`${componentName}\`.` + + `\nBad object: ` + JSON.stringify(props[propName], null, ' ') + + `\nValid keys: ` + JSON.stringify(Object.keys(shapeTypes), null, ' ') + ); + } + var error = checker(propValue, key, componentName, location); + if (error) { + invariant( + false, + error.message + + `\nBad object: ` + JSON.stringify(props[propName], null, ' ') + ); + return error; + } + } + } + var chainedCheckType = checkType.bind(null, false); + chainedCheckType.isRequired = checkType.bind(null, true); + return chainedCheckType; +} + +module.exports = createStrictShapeTypeChecker; diff --git a/Libraries/Utilities/deepFreezeAndThrowOnMutationInDev.js b/Libraries/Utilities/deepFreezeAndThrowOnMutationInDev.js new file mode 100644 index 0000000000..3610461d33 --- /dev/null +++ b/Libraries/Utilities/deepFreezeAndThrowOnMutationInDev.js @@ -0,0 +1,57 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule deepFreezeAndThrowOnMutationInDev + */ + +/** + * If your application is accepting different values for the same field over + * time and is doing a diff on them, you can either (1) create a copy or + * (2) ensure that those values are not mutated behind two passes. + * This function helps you with (2) by freezing the object and throwing if + * the user subsequently modifies the value. + * + * There are two caveats with this function: + * - If the call site is not in strict mode, it will only throw when + * mutating existing fields, adding a new one + * will unfortunately fail silently :( + * - If the object is already frozen or sealed, it will not continue the + * deep traversal and will leave leaf nodes unfrozen. + * + * Freezing the object and adding the throw mechanism is expensive and will + * only be used in DEV. + */ +function deepFreezeAndThrowOnMutationInDev(object) { + if (__DEV__) { + if (typeof object !== 'object' || + object === null || + Object.isFrozen(object) || + Object.isSealed(object)) { + return; + } + + for (var key in object) { + if (object.hasOwnProperty(key)) { + object.__defineGetter__(key, identity.bind(null, object[key])); + object.__defineSetter__(key, throwOnImmutableMutation.bind(null, key)); + deepFreezeAndThrowOnMutationInDev(object[key]); + } + } + Object.freeze(object); + Object.seal(object); + } +} + +function throwOnImmutableMutation(key, value) { + throw Error( + 'You attempted to set the key `' + key + '` with the value `' + + JSON.stringify(value) + '` on an object that is meant to be immutable ' + + 'and has been frozen.' + ); +} + +function identity(value) { + return value; +} + +module.exports = deepFreezeAndThrowOnMutationInDev; diff --git a/Libraries/Utilities/differ/deepDiffer.js b/Libraries/Utilities/differ/deepDiffer.js new file mode 100644 index 0000000000..97c3f516fe --- /dev/null +++ b/Libraries/Utilities/differ/deepDiffer.js @@ -0,0 +1,48 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule deepDiffer + * @flow + */ +'use strict'; + +/* + * @returns {bool} true if different, false if equal + */ +var deepDiffer = function(one: any, two: any): bool { + if (one === two) { + // Short circuit on identical object references instead of traversing them. + return false; + } + if ((typeof one === 'function') && (typeof two === 'function')) { + // We consider all functions equal + return false; + } + if ((typeof one !== 'object') || (one === null)) { + // Primitives can be directly compared + return one !== two; + } + if ((typeof two !== 'object') || (two === null)) { + // We know they are different because the previous case would have triggered + // otherwise. + return true; + } + if (one.constructor !== two.constructor) { + return true; + } + for (var key in one) { + if (deepDiffer(one[key], two[key])) { + return true; + } + } + for (var twoKey in two) { + // The only case we haven't checked yet is keys that are in two but aren't + // in one, which means they are different. + if (one[twoKey] === undefined) { + return true; + } + } + return false; +}; + +module.exports = deepDiffer; diff --git a/Libraries/Utilities/differ/insetsDiffer.js b/Libraries/Utilities/differ/insetsDiffer.js new file mode 100644 index 0000000000..07b8c08ab4 --- /dev/null +++ b/Libraries/Utilities/differ/insetsDiffer.js @@ -0,0 +1,26 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule insetsDiffer + */ +'use strict'; + +var dummyInsets = { + top: undefined, + left: undefined, + right: undefined, + bottom: undefined, +}; + +var insetsDiffer = function(one, two) { + one = one || dummyInsets; + two = two || dummyInsets; + return one !== two && ( + one.top !== two.top || + one.left !== two.left || + one.right !== two.right || + one.bottom !== two.bottom + ); +}; + +module.exports = insetsDiffer; diff --git a/Libraries/Utilities/differ/matricesDiffer.js b/Libraries/Utilities/differ/matricesDiffer.js new file mode 100644 index 0000000000..c4d1109e7d --- /dev/null +++ b/Libraries/Utilities/differ/matricesDiffer.js @@ -0,0 +1,39 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule matricesDiffer + */ +'use strict'; + +/** + * Unrolls an array comparison specially for matrices. Prioritizes + * checking of indices that are most likely to change so that the comparison + * bails as early as possible. + * + * @param {MatrixMath.Matrix} one First matrix. + * @param {MatrixMath.Matrix} two Second matrix. + * @return {boolean} Whether or not the two matrices differ. + */ +var matricesDiffer = function(one, two) { + if (one === two) { + return false; + } + return !one || !two || + one[12] !== two[12] || + one[13] !== two[13] || + one[14] !== two[14] || + one[5] !== two[5] || + one[10] !== two[10] || + one[1] !== two[1] || + one[2] !== two[2] || + one[3] !== two[3] || + one[4] !== two[4] || + one[6] !== two[6] || + one[7] !== two[7] || + one[8] !== two[8] || + one[9] !== two[9] || + one[11] !== two[11] || + one[15] !== two[15]; +}; + +module.exports = matricesDiffer; diff --git a/Libraries/Utilities/differ/pointsDiffer.js b/Libraries/Utilities/differ/pointsDiffer.js new file mode 100644 index 0000000000..76eff60ba8 --- /dev/null +++ b/Libraries/Utilities/differ/pointsDiffer.js @@ -0,0 +1,19 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule pointsDiffer + */ +'use strict'; + +var dummyPoint = {x: undefined, y: undefined}; + +var pointsDiffer = function(one, two) { + one = one || dummyPoint; + two = two || dummyPoint; + return one !== two && ( + one.x !== two.x || + one.y !== two.y + ); +}; + +module.exports = pointsDiffer; diff --git a/Libraries/Utilities/logError.js b/Libraries/Utilities/logError.js new file mode 100644 index 0000000000..8f14c4995c --- /dev/null +++ b/Libraries/Utilities/logError.js @@ -0,0 +1,22 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule logError + */ +'use strict'; + +/** + * Small utility that can be used as an error handler. You cannot just pass + * `console.error` as a failure callback - it's not properly bound. If passes an + * `Error` object, it will print the message and stack. + */ +var logError = function() { + if (arguments.length === 1 && arguments[0] instanceof Error) { + var err = arguments[0]; + console.error('Error: "' + err.message + '". Stack:\n' + err.stack); + } else { + console.error.apply(console, arguments); + } +}; + +module.exports = logError; diff --git a/Libraries/Utilities/mergeFast.js b/Libraries/Utilities/mergeFast.js new file mode 100644 index 0000000000..2d6a82a528 --- /dev/null +++ b/Libraries/Utilities/mergeFast.js @@ -0,0 +1,28 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule mergeFast + */ +'use strict'; + +/** + * Faster version of `merge` that doesn't check its arguments and + * also merges prototye inherited properties. + * + * @param {object} one Any non-null object. + * @param {object} two Any non-null object. + * @return {object} Merging of two objects, including prototype + * inherited properties. + */ +var mergeFast = function(one, two) { + var ret = {}; + for (var keyOne in one) { + ret[keyOne] = one[keyOne]; + } + for (var keyTwo in two) { + ret[keyTwo] = two[keyTwo]; + } + return ret; +}; + +module.exports = mergeFast; diff --git a/Libraries/Utilities/mergeIntoFast.js b/Libraries/Utilities/mergeIntoFast.js new file mode 100644 index 0000000000..d3788a4e74 --- /dev/null +++ b/Libraries/Utilities/mergeIntoFast.js @@ -0,0 +1,21 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule mergeIntoFast + */ +'use strict'; + +/** + * Faster version of `mergeInto` that doesn't check its arguments and + * also copies over prototye inherited properties. + * + * @param {object} one Object to assign to. + * @param {object} two Object to assign from. + */ +var mergeIntoFast = function(one, two) { + for (var keyTwo in two) { + one[keyTwo] = two[keyTwo]; + } +}; + +module.exports = mergeIntoFast; diff --git a/Libraries/Utilities/truncate.js b/Libraries/Utilities/truncate.js new file mode 100644 index 0000000000..2ffcfe10aa --- /dev/null +++ b/Libraries/Utilities/truncate.js @@ -0,0 +1,32 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule truncate + */ +'use strict'; + +var merge = require('merge'); + +var defaultOptions = { + breakOnWords: true, + minDelta: 10, // Prevents truncating a tiny bit off the end + elipsis: '...', +}; + +// maxChars (including elipsis) +var truncate = function(str, maxChars, options) { + options = merge(defaultOptions, options); + if (str && str.length && + str.length - options.minDelta + options.elipsis.length >= maxChars) { + str = str.slice(0, maxChars - options.elipsis.length + 1); + if (options.breakOnWords) { + var ii = Math.max(str.lastIndexOf(' '), str.lastIndexOf('\n')); + str = str.slice(0, ii); + } + str = str.trim() + options.elipsis; + } + return str; +}; + +module.exports = truncate; + diff --git a/Libraries/Utilities/validAttributesFromPropTypes.js b/Libraries/Utilities/validAttributesFromPropTypes.js new file mode 100644 index 0000000000..d08d17cfa6 --- /dev/null +++ b/Libraries/Utilities/validAttributesFromPropTypes.js @@ -0,0 +1,20 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule validAttributesFromPropTypes + */ +'use strict' + +function validAttributesFromPropTypes(propTypes) { + var validAttributes = {}; + for (var key in propTypes) { + var propType = propTypes[key]; + if (propType && propType.isNative) { + var diff = propType.differ; + validAttributes[key] = diff ? {diff} : true; + } + } + return validAttributes; +} + +module.exports = validAttributesFromPropTypes; diff --git a/Libraries/XMLHttpRequest/XMLHttpRequest.ios.js b/Libraries/XMLHttpRequest/XMLHttpRequest.ios.js new file mode 100644 index 0000000000..042cbe4b93 --- /dev/null +++ b/Libraries/XMLHttpRequest/XMLHttpRequest.ios.js @@ -0,0 +1,163 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @providesModule XMLHttpRequest + * @flow + */ +'use strict'; + +var RKDataManager = require('NativeModulesDeprecated').RKDataManager; + +var crc32 = require('crc32'); + +class XMLHttpRequest { + + UNSENT: number; + OPENED: number; + HEADERS_RECEIVED: number; + LOADING: number; + DONE: number; + + onreadystatechange: ?Function; + onload: ?Function; + upload: any; + readyState: number; + responseHeaders: ?Object; + responseText: ?string; + status: ?string; + + _method: ?string; + _url: ?string; + _headers: Object; + _sent: boolean; + _aborted: boolean; + + constructor() { + this.UNSENT = 0; + this.OPENED = 1; + this.HEADERS_RECEIVED = 2; + this.LOADING = 3; + this.DONE = 4; + + this.onreadystatechange = undefined; + this.upload = undefined; /* Upload not supported */ + this.readyState = this.UNSENT; + this.responseHeaders = undefined; + this.responseText = undefined; + this.status = undefined; + + this._method = null; + this._url = null; + this._headers = {}; + this._sent = false; + this._aborted = false; + } + + getAllResponseHeaders(): ?string { + /* Stub */ + return ''; + } + + getResponseHeader(header: string): ?string { + /* Stub */ + return ''; + } + + setRequestHeader(header: string, value: any): void { + this._headers[header] = value; + } + + open(method: string, url: string, async: ?boolean): void { + /* Other optional arguments are not supported */ + if (this.readyState !== this.UNSENT) { + throw new Error('Cannot open, already sending'); + } + if (async !== undefined && !async) { + // async is default + throw new Error('Synchronous http requests are not supported'); + } + this._method = method; + this._url = url; + this._aborted = false; + this._setReadyState(this.OPENED); + } + + send(data: any): void { + if (this.readyState !== this.OPENED) { + throw new Error('Request has not been opened'); + } + if (this._sent) { + throw new Error('Request has already been sent'); + } + this._sent = true; + + RKDataManager.queryData( + 'http', + JSON.stringify({ + method: this._method, + url: this._url, + data: data, + headers: this._headers, + }), + 'h' + crc32(this._method + '|' + this._url + '|' + data), + (result) => { + result = JSON.parse(result); + this._callback(result.status, result.responseHeaders, result.responseText); + } + ); + } + + abort(): void { + console.warn( + 'XMLHttpRequest: abort() cancels JS callbacks ' + + 'but not native HTTP request.' + ); + // only call onreadystatechange if there is something to abort, + // below logic is per spec + if (!(this.readyState === this.UNSENT || + (this.readyState === this.OPENED && !this._sent) || + this.readyState === this.DONE)) { + this._sent = false; + this._setReadyState(this.DONE); + } + if (this.readyState === this.DONE) { + this._sendLoad(); + } + this.readyState = this.UNSENT; + this._aborted = true; + } + + _setReadyState(newState: number): void { + this.readyState = newState; + // TODO: workaround flow bug with nullable function checks + var onreadystatechange = this.onreadystatechange; + if (onreadystatechange) { + // We should send an event to handler, but since we don't process that + // event anywhere, let's leave it empty + onreadystatechange(null); + } + } + + _sendLoad(): void { + // TODO: workaround flow bug with nullable function checks + var onload = this.onload; + if (onload) { + // We should send an event to handler, but since we don't process that + // event anywhere, let's leave it empty + onload(null); + } + } + + _callback(status: string, responseHeaders: ?Object, responseText: string): void { + if (this._aborted) { + return; + } + this.status = status; + this.responseHeaders = responseHeaders; + this.responseText = responseText; + this._setReadyState(this.DONE); + this._sendLoad(); + } +} + +module.exports = XMLHttpRequest; diff --git a/Libraries/react-native/addons.js b/Libraries/react-native/addons.js new file mode 100644 index 0000000000..fcef285e22 --- /dev/null +++ b/Libraries/react-native/addons.js @@ -0,0 +1,34 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @flow + */ +'use strict'; + +var LinkedStateMixin = require('LinkedStateMixin'); +var ReactComponentWithPureRenderMixin = require('ReactComponentWithPureRenderMixin'); +var ReactNative = require('react-native'); +var ReactUpdates = require('ReactUpdates'); + +var cloneWithProps = require('cloneWithProps'); +var update = require('update'); + +var addons = { + LinkedStateMixin: LinkedStateMixin, + PureRenderMixin: ReactComponentWithPureRenderMixin, + batchedUpdates: ReactUpdates.batchedUpdates, + cloneWithProps: cloneWithProps, + update: update, +}; + +if (__DEV__) { + addons.Perf = require('ReactDefaultPerf'); + addons.TestUtils = require('ReactTestUtils'); +} + +var ReactNativeWithAddons = { + ...ReactNative, + addons: addons, +}; + +module.exports = ReactNativeWithAddons; diff --git a/Libraries/react-native/package.json b/Libraries/react-native/package.json new file mode 100644 index 0000000000..e8306d4858 --- /dev/null +++ b/Libraries/react-native/package.json @@ -0,0 +1,5 @@ +{ + "name": "react-native", + "main": "react-native.js", + "directories": {".": ""} +} diff --git a/Libraries/react-native/react-native-interface.js b/Libraries/react-native/react-native-interface.js new file mode 100644 index 0000000000..c37bbe6857 --- /dev/null +++ b/Libraries/react-native/react-native-interface.js @@ -0,0 +1,36 @@ +declare module "react-native" { + declare class ListViewDataSource { + constructor(params: Object): void; + } + + declare var AppRegistry: ReactClass; + declare var ExpandingText: ReactClass; + declare var Image: ReactClass; + declare var ListView: ReactClass; + declare var NavigatorIOS: ReactClass; + declare var NavigatorItemIOS: ReactClass; + declare var PixelRatio: ReactClass; + declare var ScrollView: ReactClass; + declare var ActivityIndicatorIOS: ReactClass; + declare var StyleSheet: ReactClass; + declare var Text: ReactClass; + declare var TextInput: ReactClass; + declare var TimerMixin: ReactClass; + declare var TouchableHighlight: ReactClass; + declare var TouchableWithoutFeedback: ReactClass; + declare var View: ReactClass; + declare var invariant: Function; + declare var ix: Function; +} + +declare module "addons" { + declare var NavigatorIOS: ReactClass; + declare var NavigatorItemIOS: ReactClass; + declare var StyleSheet: ReactClass; +} + +declare var __DEV__: boolean; + +declare module "fetch" { + declare function exports(url: string, options?: Object): Object; +} diff --git a/Libraries/react-native/react-native.js b/Libraries/react-native/react-native.js new file mode 100644 index 0000000000..78c93960b2 --- /dev/null +++ b/Libraries/react-native/react-native.js @@ -0,0 +1,33 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * @flow + */ +'use strict'; + +var ReactNative = { + ...require('React'), + AppRegistry: require('AppRegistry'), + ExpandingText: require('ExpandingText'), + Image: require('Image'), + LayoutAnimation: require('LayoutAnimation'), + ListView: require('ListView'), + ListViewDataSource: require('ListViewDataSource'), + NavigatorIOS: require('NavigatorIOS'), + PixelRatio: require('PixelRatio'), + ScrollView: require('ScrollView'), + ActivityIndicatorIOS: require('ActivityIndicatorIOS'), + StatusBarIOS: require('StatusBarIOS'), + StyleSheet: require('StyleSheet'), + Text: require('Text'), + TextInput: require('TextInput'), + TimerMixin: require('TimerMixin'), + TouchableHighlight: require('TouchableHighlight'), + TouchableOpacity: require('TouchableOpacity'), + TouchableWithoutFeedback: require('TouchableWithoutFeedback'), + View: require('View'), + invariant: require('invariant'), + ix: require('ix'), +}; + +module.exports = ReactNative; diff --git a/Libraries/vendor/core/ES6Promise.js b/Libraries/vendor/core/ES6Promise.js new file mode 100644 index 0000000000..acbf02773f --- /dev/null +++ b/Libraries/vendor/core/ES6Promise.js @@ -0,0 +1,364 @@ +/** + * @generated SignedSource<> + * + * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + * !! This file is a check-in of a static_upstream project! !! + * !! !! + * !! You should not modify this file directly. Instead: !! + * !! 1) Use `fjs use-upstream` to temporarily replace this with !! + * !! the latest version from upstream. !! + * !! 2) Make your changes, test them, etc. !! + * !! 3) Use `fjs push-upstream` to copy your changes back to !! + * !! static_upstream. !! + * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + * + * Copyright 2013-2014 Facebook, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @providesModule ES6Promise + * + * This module implements the minimum functionality necessary to comply + * with chapter 25.4 of the ES6 specification. Any extensions to Promise + * or Promise.prototype should be added in the Promise module. + * + * people.mozilla.org/~jorendorff/es6-draft.html#sec-promise-objects + */ + +module.exports = (function(global, undefined) { + 'use strict'; + + var setImmediate = require('setImmediate'); + + // These are the possible values for slots(promise).state. + var PENDING_STATE = 'pending'; + var FULFILLED_STATE = 'fulfilled'; + var REJECTED_STATE = 'rejected'; + + // The ES6 specification makes heavy use of a notion of internal slots. + // Some of these slots are best implemented as closure variables, such + // as the alreadySettled variable in createResolvingFunctions, which + // corresponds to the resolve.[[AlreadyResolved]].value property in the + // specification. Other slots are best implemented as properties of a + // slots object attached to the host object by a pseudo-private + // property. The latter kind of slots may be accessed by passing the + // host object (such as a Promise or a resolve/reject function object) + // to the slots function; e.g., the slots(promise).state slot, which + // corresponds to promise.[[PromiseState]] in the specification. + var slotsKey = '__slots$' + Math.random().toString(36).slice(2); + function slots(obj) { + var result = obj[slotsKey]; + if (!result) { + // In ES5+ environments, this property will be safely non-writable, + // non-configurable, and non-enumerable. This implementation does + // not logically rely on those niceties, however, so this code works + // just fine in pre-ES5 environments, too. + obj[slotsKey] = result = {}; + if (Object.defineProperty) try { + Object.defineProperty(obj, slotsKey, { value: result }); + } catch (definePropertyIsBrokenInIE8) {} + } + return result; + } + + // Reusable callback functions. The identify function is the default + // when onFulfilled is undefined or null, and the raise function is the + // default when onRejected is undefined or null. + function identity(x) { return x; } + function raise(x) { throw x; } + + /** + * When the Promise function is called with argument executor, the + * following steps are taken: + * people.mozilla.org/~jorendorff/es6-draft.html#sec-promise + * + * The executor argument must be a function object. It is called for + * initiating and reporting completion of the possibly deferred action + * represented by this Promise object. The executor is called with two + * arguments: resolve and reject. These are functions that may be used + * by the executor function to report eventual completion or failure of + * the deferred computation. Returning from the executor function does + * not mean that the deferred action has been completed, but only that + * the request to eventually perform the deferred action has been + * accepted. + * + * The resolve function that is passed to an executor function accepts a + * single argument. The executor code may eventually call the resolve + * function to indicate that it wishes to resolve the associated Promise + * object. The argument passed to the resolve function represents the + * eventual value of the deferred action and can be either the actual + * fulfillment value or another Promise object which will provide the + * value if it is fullfilled. + * + * The reject function that is passed to an executor function accepts a + * single argument. The executor code may eventually call the reject + * function to indicate that the associated Promise is rejected and will + * never be fulfilled. The argument passed to the reject function is + * used as the rejection value of the promise. Typically it will be an + * Error object. + * + * When Promise is called as a function rather than as a constructor, it + * initializes its this value with the internal state necessary to + * support the Promise.prototype methods. + * + * The Promise constructor is designed to be subclassable. It may be + * used as the value in an extends clause of a class + * definition. Subclass constructors that intend to inherit the + * specified Promise behaviour must include a super call to Promise, + * e.g. by invoking Promise.call(this, executor). + * + * people.mozilla.org/~jorendorff/es6-draft.html#sec-promise-constructor + */ + function Promise(executor) { + var promiseSlots = slots(this); + promiseSlots.state = PENDING_STATE; + promiseSlots.fulfillReactions = []; + promiseSlots.rejectReactions = []; + + var resolvingFunctions = createResolvingFunctions(this); + var reject = resolvingFunctions.reject; + + try { + executor(resolvingFunctions.resolve, reject); + } catch (err) { + reject(err); + } + } + + function createResolvingFunctions(promise) { + var alreadySettled = false; + + return { + resolve: function(resolution) { + if (!alreadySettled) { + alreadySettled = true; + + if (resolution === promise) { + return settlePromise( + promise, + REJECTED_STATE, + new TypeError('Cannot resolve promise with itself') + ); + } + + // To be treated as a Promise-like object, the resolution only + // needs to be an object with a callable .then method. + if (!resolution || + typeof resolution !== "object" || + typeof resolution.then !== "function") { + return settlePromise(promise, FULFILLED_STATE, resolution); + } + + var resolvingFunctions = createResolvingFunctions(promise); + var reject = resolvingFunctions.reject; + + try { + resolution.then(resolvingFunctions.resolve, reject); + } catch (err) { + reject(err); + } + } + }, + + reject: function(reason) { + if (!alreadySettled) { + alreadySettled = true; + settlePromise(promise, REJECTED_STATE, reason); + } + } + }; + } + + // This function unifies the FulfillPromise and RejectPromise functions + // defined in the ES6 specification. + function settlePromise(promise, state, result) { + var promiseSlots = slots(promise); + if (promiseSlots.state !== PENDING_STATE) { + throw new Error('Settling a ' + promiseSlots.state + ' promise'); + } + + var reactions; + if (state === FULFILLED_STATE) { + reactions = promiseSlots.fulfillReactions; + } else if (state === REJECTED_STATE) { + reactions = promiseSlots.rejectReactions; + } + + promiseSlots.result = result; + promiseSlots.fulfillReactions = undefined; + promiseSlots.rejectReactions = undefined; + promiseSlots.state = state; + + var count = reactions.length; + count && setImmediate(function() { + for (var i = 0; i < count; ++i) { + reactions[i](promiseSlots.result); + } + }); + } + + /** + * The Promise.all function returns a new promise which is fulfilled + * with an array of fulfillment values for the passed promises, or + * rejects with the reason of the first passed promise that rejects. It + * resoves all elements of the passed iterable to promises as it runs + * this algorithm. + * + * people.mozilla.org/~jorendorff/es6-draft.html#sec-promise.all + */ + Promise.all = function(array) { + var Promise = this; + return new Promise(function(resolve, reject) { + var results = []; + var remaining = 0; + array.forEach(function(element, index) { + ++remaining; // Array might be sparse. + Promise.resolve(element).then(function(result) { + if (!results.hasOwnProperty(index)) { + results[index] = result; + --remaining || resolve(results); + } + }, reject); + }); + remaining || resolve(results); + }); + }; + + /** + * The Promise.race function returns a new promise which is settled in + * the same way as the first passed promise to settle. It resolves all + * elements of the passed iterable to promises as it runs this + * algorithm. + * + * people.mozilla.org/~jorendorff/es6-draft.html#sec-promise.race + */ + Promise.race = function(array) { + var Promise = this; + return new Promise(function(resolve, reject) { + array.forEach(function(element) { + Promise.resolve(element).then(resolve, reject); + }); + }); + }; + + /** + * The Promise.resolve function returns either a new promise resolved + * with the passed argument, or the argument itself if the argument a + * promise produced by this construtor. + * + * people.mozilla.org/~jorendorff/es6-draft.html#sec-promise.resolve + */ + Promise.resolve = function(x) { + return x instanceof Promise && x.constructor === this + ? x // Refuse to create promises for promises. + : new this(function(resolve) { resolve(x); }); + }; + + /** + * The Promise.reject function returns a new promise rejected with the + * passed argument. + * + * people.mozilla.org/~jorendorff/es6-draft.html#sec-promise.reject + */ + Promise.reject = function(r) { + return new this(function(_, reject) { reject(r); }); + }; + + var Pp = Promise.prototype; + + /** + * When the .then method is called with arguments onFulfilled and + * onRejected, the following steps are taken: + * + * people.mozilla.org/~jorendorff/es6-draft.html#sec-promise.prototype.then + */ + Pp.then = function(onFulfilled, onRejected) { + var capabilityResolve; + var capabilityReject; + var capabilityPromise = new this.constructor(function(resolve, reject) { + capabilityResolve = resolve; + capabilityReject = reject; + }); + + if (typeof capabilityResolve !== "function") { + throw new TypeError('Uncallable Promise resolve function'); + } + + if (typeof capabilityReject !== "function") { + throw new TypeError('Uncallable Promise reject function'); + } + + if (onFulfilled === undefined || onFulfilled === null) { + onFulfilled = identity; + } + + if (onRejected === undefined || onRejected === null) { + onRejected = raise; + } + + var promiseSlots = slots(this); + var state = promiseSlots.state; + if (state === PENDING_STATE) { + promiseSlots.fulfillReactions.push(makeReaction( + capabilityResolve, + capabilityReject, + onFulfilled + )); + + promiseSlots.rejectReactions.push(makeReaction( + capabilityResolve, + capabilityReject, + onRejected + )); + + } else if (state === FULFILLED_STATE || state === REJECTED_STATE) { + setImmediate(makeReaction( + capabilityResolve, + capabilityReject, + state === FULFILLED_STATE ? onFulfilled : onRejected, + promiseSlots.result + )); + } + + return capabilityPromise; + }; + + function makeReaction(resolve, reject, handler, argument) { + var hasArgument = arguments.length > 3; + return function(result) { + try { + result = handler(hasArgument ? argument : result); + } catch (err) { + reject(err); + return; + } + resolve(result); + }; + } + + /** + * When the .catch method is called with argument onRejected, the + * following steps are taken: + * + * people.mozilla.org/~jorendorff/es6-draft.html#sec-promise.prototype.catch + */ + Pp['catch'] = function(onRejected) { + return this.then(undefined, onRejected); + }; + + Pp.toString = function() { + return '[object Promise]'; + }; + + return Promise; +}(/* jslint evil: true */ Function('return this')())); diff --git a/Libraries/vendor/core/Map.js b/Libraries/vendor/core/Map.js new file mode 100644 index 0000000000..114add77b3 --- /dev/null +++ b/Libraries/vendor/core/Map.js @@ -0,0 +1,626 @@ +/** + * @generated SignedSource<<375749f44ce7c0f681fc1297943eaf74>> + * + * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + * !! This file is a check-in of a static_upstream project! !! + * !! !! + * !! You should not modify this file directly. Instead: !! + * !! 1) Use `fjs use-upstream` to temporarily replace this with !! + * !! the latest version from upstream. !! + * !! 2) Make your changes, test them, etc. !! + * !! 3) Use `fjs push-upstream` to copy your changes back to !! + * !! static_upstream. !! + * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + * + * Copyright 2013-2014 Facebook, Inc. + * @providesModule Map + * @preventMunge + * @typechecks + */ + +var guid = require('guid'); +var isNode = require('isNode'); +var toIterator = require('toIterator'); +var _shouldPolyfillES6Collection = require('_shouldPolyfillES6Collection'); + +module.exports = (function(global, undefined) { + // Since our implementation is spec-compliant for the most part we can safely + // delegate to a built-in version if exists and is implemented correctly. + // Firefox had gotten a few implementation details wrong across different + // versions so we guard against that. + if (!_shouldPolyfillES6Collection('Map')) { + return global.Map; + } + + /** + * == ES6 Map Collection == + * + * This module is meant to implement a Map collection as described in chapter + * 23.1 of the ES6 specification. + * + * Map objects are collections of key/value pairs where both the keys and + * values may be arbitrary ECMAScript language values. A distinct key value + * may only occur in one key/value pair within the Map's collection. + * + * https://people.mozilla.org/~jorendorff/es6-draft.html#sec-map-objects + * + * There only two -- rather small -- diviations from the spec: + * + * 1. The use of frozen objects as keys. + * We decided not to allow and simply throw an error. The reason being is + * we store a "hash" on the object for fast access to it's place in the + * internal map entries. + * If this turns out to be a popular use case it's possible to implement by + * overiding `Object.freeze` to store a "hash" property on the object + * for later use with the map. + * + * 2. The `size` property on a map object is a regular property and not a + * computed property on the prototype as described by the spec. + * The reason being is that we simply want to support ES3 environments + * which doesn't implement computed properties. + * + * == Usage == + * + * var map = new Map(iterable); + * + * map.set(key, value); + * map.get(key); // value + * map.has(key); // true + * map.delete(key); // true + * + * var iterator = map.keys(); + * iterator.next(); // {value: key, done: false} + * + * var iterator = map.values(); + * iterator.next(); // {value: value, done: false} + * + * var iterator = map.entries(); + * iterator.next(); // {value: [key, value], done: false} + * + * map.forEach(function(value, key){ this === thisArg }, thisArg); + * + * map.clear(); // resets map. + */ + + /** + * Constants + */ + + // Kinds of map iterations 23.1.5.3 + var KIND_KEY = 'key'; + var KIND_VALUE = 'value'; + var KIND_KEY_VALUE = 'key+value'; + + // In older browsers we can't create a null-prototype object so we have to + // defend against key collisions with built-in methods. + var KEY_PREFIX = '$map_'; + + // This property will be used as the internal size variable to disallow + // writing and to issue warnings for writings in development. + var SECRET_SIZE_PROP; + if (__DEV__) { + SECRET_SIZE_PROP = '$size' + guid(); + } + + // In oldIE we use the DOM Node `uniqueID` property to get create the hash. + var OLD_IE_HASH_PREFIX = 'IE_HASH_'; + + class Map { + + /** + * 23.1.1.1 + * Takes an `iterable` which is basically any object that implements a + * Symbol.iterator (@@iterator) method. The iterable is expected to be a + * collection of pairs. Each pair is a key/value pair that will be used + * to instantiate the map. + * + * @param {*} iterable + */ + constructor(iterable) { + if (!isObject(this)) { + throw new TypeError('Wrong map object type.'); + } + + initMap(this); + + if (iterable != null) { + var it = toIterator(iterable); + var next; + while (!(next = it.next()).done) { + if (!isObject(next.value)) { + throw new TypeError('Expected iterable items to be pair objects.'); + } + this.set(next.value[0], next.value[1]); + } + } + } + + /** + * 23.1.3.1 + * Clears the map from all keys and values. + */ + clear() { + initMap(this); + } + + /** + * 23.1.3.7 + * Check if a key exists in the collection. + * + * @param {*} key + * @return {boolean} + */ + has(key) { + var index = getIndex(this, key); + return !!(index != null && this._mapData[index]); + } + + /** + * 23.1.3.9 + * Adds a key/value pair to the collection. + * + * @param {*} key + * @param {*} value + * @return {map} + */ + set(key, value) { + var index = getIndex(this, key); + + if (index != null && this._mapData[index]) { + this._mapData[index][1] = value; + } else { + index = this._mapData.push([ + key, + value + ]) - 1; + setIndex(this, key, index); + if (__DEV__) { + this[SECRET_SIZE_PROP] += 1; + } else { + this.size += 1; + } + } + + return this; + } + + /** + * 23.1.3.6 + * Gets a value associated with a key in the collection. + * + * @param {*} key + * @return {*} + */ + get(key) { + var index = getIndex(this, key); + if (index == null) { + return undefined; + } else { + return this._mapData[index][1]; + } + } + + + /** + * 23.1.3.3 + * Delete a key/value from the collection. + * + * @param {*} key + * @return {boolean} Whether the key was found and deleted. + */ + delete(key) { + var index = getIndex(this, key); + if (index != null && this._mapData[index]) { + setIndex(this, key, undefined); + this._mapData[index] = undefined; + if (__DEV__) { + this[SECRET_SIZE_PROP] -= 1; + } else { + this.size -= 1; + } + return true; + } else { + return false; + } + } + + /** + * 23.1.3.4 + * Returns an iterator over the key/value pairs (in the form of an Array) in + * the collection. + * + * @return {MapIterator} + */ + entries() { + return new MapIterator(this, KIND_KEY_VALUE); + } + + /** + * 23.1.3.8 + * Returns an iterator over the keys in the collection. + * + * @return {MapIterator} + */ + keys() { + return new MapIterator(this, KIND_KEY); + } + + /** + * 23.1.3.11 + * Returns an iterator over the values pairs in the collection. + * + * @return {MapIterator} + */ + values() { + return new MapIterator(this, KIND_VALUE); + } + + /** + * 23.1.3.5 + * Iterates over the key/value pairs in the collection calling `callback` + * with [value, key, map]. An optional `thisArg` can be passed to set the + * context when `callback` is called. + * + * @param {function} callback + * @param {?object} thisArg + */ + forEach(callback, thisArg) { + if (typeof callback !== 'function') { + throw new TypeError('Callback must be callable.'); + } + + var boundCallback = callback.bind(thisArg || undefined); + var mapData = this._mapData; + + // Note that `mapData.length` should be computed on each iteration to + // support iterating over new items in the map that were added after the + // start of the iteration. + for (var i = 0; i < mapData.length; i++) { + var entry = mapData[i]; + if (entry != null) { + boundCallback(entry[1], entry[0], this); + } + } + } + } + + // 23.1.3.12 + Map.prototype[toIterator.ITERATOR_SYMBOL] = Map.prototype.entries; + + class MapIterator { + + /** + * 23.1.5.1 + * Create a `MapIterator` for a given `map`. While this class is private it + * will create objects that will be passed around publicily. + * + * @param {map} map + * @param {string} kind + */ + constructor(map, kind) { + if (!(isObject(map) && map['_mapData'])) { + throw new TypeError('Object is not a map.'); + } + + if ([KIND_KEY, KIND_KEY_VALUE, KIND_VALUE].indexOf(kind) === -1) { + throw new Error('Invalid iteration kind.'); + } + + this._map = map; + this._nextIndex = 0; + this._kind = kind; + } + + /** + * 23.1.5.2.1 + * Get the next iteration. + * + * @return {object} + */ + next() { + if (!this instanceof Map) { + throw new TypeError('Expected to be called on a MapIterator.'); + } + + var map = this._map; + var index = this._nextIndex; + var kind = this._kind; + + if (map == null) { + return createIterResultObject(undefined, true); + } + + var entries = map['_mapData']; + + while (index < entries.length) { + var record = entries[index]; + + index += 1; + this._nextIndex = index; + + if (record) { + if (kind === KIND_KEY) { + return createIterResultObject(record[0], false); + } else if (kind === KIND_VALUE) { + return createIterResultObject(record[1], false); + } else if (kind) { + return createIterResultObject(record, false); + } + } + } + + this._map = undefined; + + return createIterResultObject(undefined, true); + } + } + + // We can put this in the class definition once we have computed props + // transform. + // 23.1.5.2.2 + MapIterator.prototype[toIterator.ITERATOR_SYMBOL] = function() { + return this; + } + + /** + * Helper Functions. + */ + + /** + * Return an index to map.[[MapData]] array for a given Key. + * + * @param {map} map + * @param {*} key + * @return {?number} + */ + function getIndex(map, key) { + if (isObject(key)) { + var hash = getHash(key); + return map._objectIndex[hash]; + } else { + var prefixedKey = KEY_PREFIX + key; + if (typeof key === 'string') { + return map._stringIndex[prefixedKey]; + } else { + return map._otherIndex[prefixedKey]; + } + } + } + + /** + * Setup an index that refer to the key's location in map.[[MapData]]. + * + * @param {map} map + * @param {*} key + */ + function setIndex(map, key, index) { + var shouldDelete = index == null; + + if (isObject(key)) { + var hash = getHash(key); + if (shouldDelete) { + delete map._objectIndex[hash]; + } else { + map._objectIndex[hash] = index; + } + } else { + var prefixedKey = KEY_PREFIX + key; + if (typeof key === 'string') { + if (shouldDelete) { + delete map._stringIndex[prefixedKey]; + } else { + map._stringIndex[prefixedKey] = index; + } + } else { + if (shouldDelete) { + delete map._otherIndex[prefixedKey]; + } else { + map._otherIndex[prefixedKey] = index; + } + } + } + } + + /** + * Instantiate a map with internal slots. + * + * @param {map} map + */ + function initMap(map) { + // Data structure design inspired by Traceur's Map implementation. + // We maintain an internal array for all the entries. The array is needed + // to remember order. However, to have a reasonable HashMap performance + // i.e. O(1) for insertion, deletion, and retrieval. We maintain indices + // in objects for fast look ups. Indices are split up according to data + // types to avoid collisions. + map._mapData = []; + + // Object index maps from an object "hash" to index. The hash being a unique + // property of our choosing that we associate with the object. Association + // is done by ways of keeping a non-enumerable property on the object. + // Ideally these would be `Object.create(null)` objects but since we're + // trying to support ES3 we'll have to gaurd against collisions using + // prefixes on the keys rather than rely on null prototype objects. + map._objectIndex = {}; + + // String index maps from strings to index. + map._stringIndex = {}; + + // Numbers, booleans, undefined, and null. + map._otherIndex = {}; + + // Unfortunately we have to support ES3 and cannot have `Map.prototype.size` + // be a getter method but just a regular method. The biggest problem with + // this is safety. Clients can change the size property easily and possibly + // without noticing (e.g. `if (map.size = 1) {..}` kind of typo). What we + // can do to mitigate use getters and setters in development to disallow + // and issue a warning for changing the `size` property. + if (__DEV__) { + if (isES5) { + // If the `SECRET_SIZE_PROP` property is already defined then we're not + // in the first call to `initMap` (e.g. coming from `map.clear()`) so + // all we need to do is reset the size without defining the properties. + if (map.hasOwnProperty(SECRET_SIZE_PROP)) { + map[SECRET_SIZE_PROP] = 0; + } else { + Object.defineProperty(map, SECRET_SIZE_PROP, { + value: 0, + writable: true + }); + Object.defineProperty(map, 'size', { + set: (v) => { + console.error( + 'PLEASE FIX ME: You are changing the map size property which ' + + 'should not be writable and will break in production.' + ); + throw new Error('The map size property is not writable.'); + }, + get: () => map[SECRET_SIZE_PROP] + }); + } + + // NOTE: Early return to implement immutable `.size` in DEV. + return; + } + } + + // This is a diviation from the spec. `size` should be a getter on + // `Map.prototype`. However, we have to support IE8. + map.size = 0; + } + + /** + * Check if something is an object. + * + * @param {*} o + * @return {boolean} + */ + function isObject(o) { + return o != null && (typeof o === 'object' || typeof o === 'function'); + } + + /** + * Create an iteration object. + * + * @param {*} value + * @param {boolean} done + * @return {object} + */ + function createIterResultObject(value, done) { + return {value, done}; + } + + // Are we in a legit ES5 environment. Spoiler alert: that doesn't include IE8. + var isES5 = (function() { + try { + Object.defineProperty({}, 'x', {}); + return true; + } catch(e) { + return false; + } + })(); + + /** + * Check if an object can be extended. + * + * @param {object|array|function|regexp} o + * @return {boolean} + */ + function isExtensible(o) { + if (!isES5) { + return true; + } else { + return Object.isExtensible(o); + } + } + + /** + * IE has a `uniqueID` set on every DOM node. So we construct the hash from + * this uniqueID to avoid memory leaks and the IE cloneNode bug where it + * clones properties in addition to the attributes. + * + * @param {object} node + * @return {?string} + */ + function getIENodeHash(node) { + var uniqueID; + switch (node.nodeType) { + case 1: // Element + uniqueID = node.uniqueID; + break; + case 9: // Document + uniqueID = node.documentElement.uniqueID; + break; + default: + return null; + } + + if (uniqueID) { + return OLD_IE_HASH_PREFIX + uniqueID; + } else { + return null; + } + } + + var getHash = (function() { + var propIsEnumerable = Object.prototype.propertyIsEnumerable; + var hashProperty = guid(); + var hashCounter = 0; + + /** + * Get the "hash" associated with an object. + * + * @param {object|array|function|regexp} o + * @return {number} + */ + return function getHash(o) { + if (o[hashProperty]) { + return o[hashProperty]; + } else if (!isES5 && + o.propertyIsEnumerable && + o.propertyIsEnumerable[hashProperty]) { + return o.propertyIsEnumerable[hashProperty]; + } else if (!isES5 && + isNode(o) && + getIENodeHash(o)) { + return getIENodeHash(o); + } else if (!isES5 && o[hashProperty]) { + return o[hashProperty]; + } + + if (isExtensible(o)) { + hashCounter += 1; + if (isES5) { + Object.defineProperty(o, hashProperty, { + enumerable: false, + writable: false, + configurable: false, + value: hashCounter + }); + } else if (o.propertyIsEnumerable) { + // Since we can't define a non-enumerable property on the object + // we'll hijack one of the less-used non-enumerable properties to + // save our hash on it. Addiotionally, since this is a function it + // will not show up in `JSON.stringify` which is what we want. + o.propertyIsEnumerable = function() { + return propIsEnumerable.apply(this, arguments); + }; + o.propertyIsEnumerable[hashProperty] = hashCounter; + } else if (isNode(o)) { + // At this point we couldn't get the IE `uniqueID` to use as a hash + // and we couldn't use a non-enumerable property to exploit the + // dontEnum bug so we simply add the `hashProperty` on the node + // itself. + o[hashProperty] = hashCounter; + } else { + throw new Error('Unable to set a non-enumerable property on object.'); + } + return hashCounter; + } else { + throw new Error('Non-extensible objects are not allowed as keys.'); + } + }; + })(); + + return Map; +})(/* jslint evil: true */ Function('return this')()); diff --git a/Libraries/vendor/core/Promise.js b/Libraries/vendor/core/Promise.js new file mode 100644 index 0000000000..1593c0fd25 --- /dev/null +++ b/Libraries/vendor/core/Promise.js @@ -0,0 +1,88 @@ +/** + * @generated SignedSource<> + * + * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + * !! This file is a check-in of a static_upstream project! !! + * !! !! + * !! You should not modify this file directly. Instead: !! + * !! 1) Use `fjs use-upstream` to temporarily replace this with !! + * !! the latest version from upstream. !! + * !! 2) Make your changes, test them, etc. !! + * !! 3) Use `fjs push-upstream` to copy your changes back to !! + * !! static_upstream. !! + * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + * + * Copyright 2013-2014 Facebook, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * @providesModule Promise + * + * This module wraps and augments the minimally ES6-compliant Promise + * implementation provided by the ES6Promise module. + */ + +var Promise = require('ES6Promise'); +var Pp = Promise.prototype; + +var invariant = require('invariant'); +var setImmediate = require('setImmediate'); +var throwImmediate = require('throwImmediate'); + +/** + * Handle either fulfillment or rejection with the same callback. + */ +Pp.finally = function(onSettled) { + return this.then(onSettled, onSettled); +}; + +/** + * Throw any unhandled error in a separate tick of the event loop. + */ +Pp.done = function(onFulfilled, onRejected) { + this.then(onFulfilled, onRejected).then(null, throwImmediate); +}; + +/** + * This function takes an object with promises as keys and returns a promise. + * The returned promise is resolved when all promises from the object are + * resolved and gets rejected when the first promise is rejected. + * + * EXAMPLE: + * var promisedMuffin = Promise.allObject({ + * dough: promisedDough, + * frosting: promisedFrosting + * }).then(function(results) { + * return combine(results.dough, results.frosting); + * }); + */ +Promise.allObject = function(/*object*/ promises) { + // Throw instead of warn here to make sure people use this only with object. + invariant( + !Array.isArray(promises), + 'expected an object, got an array instead' + ); + + var keys = Object.keys(promises); + return Promise.all(keys.map(function(key) { + return promises[key]; + })).then(function(values) { + var answers = {}; + values.forEach(function(value, i) { + answers[keys[i]] = value; + }); + return answers; + }); +}; + +module.exports = Promise; diff --git a/Libraries/vendor/core/Set.js b/Libraries/vendor/core/Set.js new file mode 100644 index 0000000000..3bd019aeaa --- /dev/null +++ b/Libraries/vendor/core/Set.js @@ -0,0 +1,201 @@ +/** + * @generated SignedSource<<1fe20877e83ba5d4d0ea68ab240df21c>> + * + * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + * !! This file is a check-in of a static_upstream project! !! + * !! !! + * !! You should not modify this file directly. Instead: !! + * !! 1) Use `fjs use-upstream` to temporarily replace this with !! + * !! the latest version from upstream. !! + * !! 2) Make your changes, test them, etc. !! + * !! 3) Use `fjs push-upstream` to copy your changes back to !! + * !! static_upstream. !! + * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + * + * Copyright 2013-2014 Facebook, Inc. + * @providesModule Set + * @preventMunge + * @typechecks + */ + +var Map = require('Map'); +var toIterator = require('toIterator'); +var _shouldPolyfillES6Collection = require('_shouldPolyfillES6Collection'); + +module.exports = (function(global, undefined) { + // Since our implementation is spec-compliant for the most part we can safely + // delegate to a built-in version if exists and is implemented correctly. + // Firefox had gotten a few implementation details wrong across different + // versions so we guard against that. + // These checks are adapted from es6-shim https://fburl.com/34437854 + if (!_shouldPolyfillES6Collection('Set')) { + return global.Set; + } + + /** + * == ES6 Set Collection == + * + * This module is meant to implement a Set collection as described in chapter + * 23.2 of the ES6 specification. + * + * Set objects are collections of unique values. Where values can be any + * JavaScript value. + * https://people.mozilla.org/~jorendorff/es6-draft.html#sec-map-objects + * + * There only two -- rather small -- diviations from the spec: + * + * 1. The use of frozen objects as keys. @see Map module for more on this. + * + * 2. The `size` property on a map object is a regular property and not a + * computed property on the prototype as described by the spec. + * The reason being is that we simply want to support ES3 environments + * which doesn't implement computed properties. + * + * == Usage == + * + * var set = new set(iterable); + * + * set.set(value); + * set.has(value); // true + * set.delete(value); // true + * + * var iterator = set.keys(); + * iterator.next(); // {value: value, done: false} + * + * var iterator = set.values(); + * iterator.next(); // {value: value, done: false} + * + * var iterator = set.entries(); + * iterator.next(); // {value: [value, value], done: false} + * + * set.forEach(function(value, value){ this === thisArg }, thisArg); + * + * set.clear(); // resets set. + */ + + class Set { + + /** + * 23.2.1.1 + * + * Takes an optional `iterable` (which is basically any object that + * implements a Symbol.iterator (@@iterator) method). That is a collection + * of values used to instantiate the set. + * + * @param {*} iterable + */ + constructor(iterable) { + if (this == null || + (typeof this !== 'object' && typeof this !== 'function')) { + throw new TypeError('Wrong set object type.'); + } + + initSet(this); + + if (iterable != null) { + var it = toIterator(iterable); + var next; + while (!(next = it.next()).done) { + this.add(next.value); + } + } + } + + /** + * 23.2.3.1 + * + * If it doesn't already exist in the collection a `value` is added. + * + * @param {*} value + * @return {set} + */ + add(value) { + this._map.set(value, value); + this.size = this._map.size; + return this; + } + + /** + * 23.2.3.2 + * + * Clears the set. + */ + clear() { + initSet(this); + } + + /** + * 23.2.3.4 + * + * Deletes a `value` from the collection if it exists. + * Returns true if the value was found and deleted and false otherwise. + * + * @param {*} value + * @return {boolean} + */ + delete(value) { + var ret = this._map.delete(value); + this.size = this._map.size; + return ret; + } + + /** + * 23.2.3.5 + * + * Returns an iterator over a collection of [value, value] tuples. + */ + entries() { + return this._map.entries(); + } + + /** + * 23.2.3.6 + * + * Iterate over the collection calling `callback` with (value, value, set). + * + * @param {function} callback + */ + forEach(callback) { + var thisArg = arguments[1]; + var it = this._map.keys(); + var next; + while (!(next = it.next()).done) { + callback.call(thisArg, next.value, next.value, this); + } + } + + /** + * 23.2.3.7 + * + * Iterate over the collection calling `callback` with (value, value, set). + * + * @param {*} value + * @return {boolean} + */ + has(value) { + return this._map.has(value); + } + + /** + * 23.2.3.7 + * + * Returns an iterator over the colleciton of values. + */ + values() { + return this._map.values(); + } + } + + // 23.2.3.11 + Set.prototype[toIterator.ITERATOR_SYMBOL] = Set.prototype.values; + + // 23.2.3.7 + Set.prototype.keys = Set.prototype.values; + + function initSet(set) { + set._map = new Map(); + set.size = set._map.size; + } + + return Set; +})(/* jslint evil: true */ Function('return this')()); diff --git a/Libraries/vendor/core/_shouldPolyfillES6Collection.js b/Libraries/vendor/core/_shouldPolyfillES6Collection.js new file mode 100644 index 0000000000..a96c001044 --- /dev/null +++ b/Libraries/vendor/core/_shouldPolyfillES6Collection.js @@ -0,0 +1,78 @@ +/** + * @generated SignedSource<<6c1a82d2f5918f03f3f0e5825e1f32f3>> + * + * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + * !! This file is a check-in of a static_upstream project! !! + * !! !! + * !! You should not modify this file directly. Instead: !! + * !! 1) Use `fjs use-upstream` to temporarily replace this with !! + * !! the latest version from upstream. !! + * !! 2) Make your changes, test them, etc. !! + * !! 3) Use `fjs push-upstream` to copy your changes back to !! + * !! static_upstream. !! + * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + * + * Copyright 2013-2014 Facebook, Inc. + * @providesModule _shouldPolyfillES6Collection + * @preventMunge + * @typechecks + */ + +/** + * Given a collection class name (Map or Set) return whether it's safe to use + * the native polyfill. + * + * @param {string} collectionName + */ +function shouldPolyfillES6Collection(collectionName) { + var Collection = global[collectionName]; + if (Collection == null) { + return true; + } + + var proto = Collection.prototype; + + // These checks are adapted from es6-shim https://fburl.com/34437854 + return Collection == null || + typeof Collection !== 'function' || + typeof proto.clear !== 'function' || + new Collection().size !== 0 || + typeof proto.keys !== 'function' || + typeof proto.forEach !== 'function' || + isCallableWithoutNew(Collection) || + !supportsSubclassing(Collection); +} + +/** + * Given a class can we subclass it? + * + * @param {function} Collection + */ +function supportsSubclassing(Collection) { + class SubCollection extends Collection {} + try { + var s = (new SubCollection([])); + // Firefox 32 will throw a type error when any operation is called on a + // subclass. + s.size; + return s instanceof Collection; + } catch (e) { + return false; + } +} + +/** + * Given a constructor can we call it without `new`? + * + * @param {function} Collection + */ +function isCallableWithoutNew(Collection) { + try { + Collection(); + } catch (e) { + return false; + } + return true; +} + +module.exports = shouldPolyfillES6Collection; diff --git a/Libraries/vendor/core/getObjectValues.js b/Libraries/vendor/core/getObjectValues.js new file mode 100644 index 0000000000..a7c9448bd4 --- /dev/null +++ b/Libraries/vendor/core/getObjectValues.js @@ -0,0 +1,36 @@ +/** + * @generated SignedSource<> + * + * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + * !! This file is a check-in of a static_upstream project! !! + * !! !! + * !! You should not modify this file directly. Instead: !! + * !! 1) Use `fjs use-upstream` to temporarily replace this with !! + * !! the latest version from upstream. !! + * !! 2) Make your changes, test them, etc. !! + * !! 3) Use `fjs push-upstream` to copy your changes back to !! + * !! static_upstream. !! + * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + * + * @providesModule getObjectValues + * @typechecks + */ + +/** + * Retrieve an object's values as an array. + * + * If you are looking for a function that creates an Array instance based + * on an "Array-like" object, use createArrayFrom instead. + * + * @param {object} obj An object. + * @return {array} The object's values. + */ +function getObjectValues(obj) { + var values = []; + for (var key in obj) { + values.push(obj[key]); + } + return values; +} + +module.exports = getObjectValues; diff --git a/Libraries/vendor/core/guid.js b/Libraries/vendor/core/guid.js new file mode 100644 index 0000000000..7f66e64da7 --- /dev/null +++ b/Libraries/vendor/core/guid.js @@ -0,0 +1,31 @@ +/** + * @generated SignedSource<<4425c6f5a34b56ee4707e090f43fd075>> + * + * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + * !! This file is a check-in of a static_upstream project! !! + * !! !! + * !! You should not modify this file directly. Instead: !! + * !! 1) Use `fjs use-upstream` to temporarily replace this with !! + * !! the latest version from upstream. !! + * !! 2) Make your changes, test them, etc. !! + * !! 3) Use `fjs push-upstream` to copy your changes back to !! + * !! static_upstream. !! + * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + * + * Copyright 2004-present Facebook. All Rights Reserved. + * + * Module that provides a function for creating a unique identifier. + * The returned value does not conform to the GUID standard, but should + * be globally unique in the context of the browser. + * + * @providesModule guid + * + */ + +/*jshint bitwise: false*/ + +function guid() { + return 'f' + (Math.random() * (1 << 30)).toString(16).replace('.', ''); +} + +module.exports = guid; diff --git a/Libraries/vendor/core/immediate/setImmediate.js b/Libraries/vendor/core/immediate/setImmediate.js new file mode 100644 index 0000000000..1040712aad --- /dev/null +++ b/Libraries/vendor/core/immediate/setImmediate.js @@ -0,0 +1,201 @@ +/** + * @generated SignedSource<<57d0446bbd1186485d372efe6b323dca>> + * + * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + * !! This file is a check-in of a static_upstream project! !! + * !! !! + * !! You should not modify this file directly. Instead: !! + * !! 1) Use `fjs use-upstream` to temporarily replace this with !! + * !! the latest version from upstream. !! + * !! 2) Make your changes, test them, etc. !! + * !! 3) Use `fjs push-upstream` to copy your changes back to !! + * !! static_upstream. !! + * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + * + * Copyright (c) 2012 Barnesandnoble.com, llc, Donavon West, and Domenic + * Denicola + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + * @preserve-header + * @providesModule ImmediateImplementation + */ + +(function(global, undefined) { + "use strict"; + + var nextHandle = 1; // Spec says greater than zero + var tasksByHandle = {}; + var queueHead = {}; + var queueTail = queueHead; + var currentlyRunningATask = false; + var doc = global.document; + var setImmediate; + + function addFromSetImmediateArguments(args) { + var handler = args[0]; + args = Array.prototype.slice.call(args, 1); + tasksByHandle[nextHandle] = function() { + handler.apply(undefined, args); + }; + queueTail = (queueTail.next = { handle: nextHandle++ }); + return queueTail.handle; + } + + function flushQueue() { + var next, task; + while (!currentlyRunningATask && (next = queueHead.next)) { + queueHead = next; // If this task fails, don't retry it. + if ((task = tasksByHandle[next.handle])) { + currentlyRunningATask = true; + try { + task(); + currentlyRunningATask = false; + } finally { + clearImmediate(next.handle); + if (currentlyRunningATask) { + currentlyRunningATask = false; + // The call to task() must have thrown an + // exception if we reach this point, so, just in + // case there are tasks remaining to be executed, + // we schedule another flushQueue in a later tick + // of the event loop, and let the exception + // propagate uncaught. + if (queueHead.next) { + setImmediate(flushQueue); + } + } + } + } + } + } + + function clearImmediate(handle) { + delete tasksByHandle[handle]; + } + + function canUsePostMessage() { + // The test against `importScripts` prevents this implementation from being installed inside a web worker, + // where `global.postMessage` means something completely different and can't be used for this purpose. + if (global.postMessage && !global.importScripts) { + var postMessageIsAsynchronous = true; + + var onMessage = function() { + postMessageIsAsynchronous = false; + if (global.removeEventListener) { + global.removeEventListener("message", onMessage, false); + } else { + global.detachEvent("onmessage", onMessage); + } + }; + + if (global.addEventListener) { + global.addEventListener("message", onMessage, false); + } else if (global.attachEvent) { + global.attachEvent("onmessage", onMessage); + } else { + return false; + } + + global.postMessage("", "*"); + return postMessageIsAsynchronous; + } + } + + function installPostMessageImplementation() { + // Installs an event handler on `global` for the `message` event: see + // * https://developer.mozilla.org/en/DOM/window.postMessage + var messagePrefix = "setImmediate$" + Math.random() + "$"; + var onGlobalMessage = function(event) { + if (event.source === global && + typeof event.data === "string" && + event.data.indexOf(messagePrefix) === 0) { + flushQueue(); + } + }; + + if (global.addEventListener) { + global.addEventListener("message", onGlobalMessage, false); + } else { + global.attachEvent("onmessage", onGlobalMessage); + } + + setImmediate = function() { + var handle = addFromSetImmediateArguments(arguments); + global.postMessage(messagePrefix + handle, "*"); + return handle; + }; + } + + function installMessageChannelImplementation() { + var channel = new MessageChannel(); + channel.port1.onmessage = flushQueue; + setImmediate = function() { + var handle = addFromSetImmediateArguments(arguments); + channel.port2.postMessage(handle); + return handle; + }; + } + + function installReadyStateChangeImplementation() { + var html = doc.documentElement; + setImmediate = function() { + var handle = addFromSetImmediateArguments(arguments); + // Create a ", + script + ]; + [_webView loadHTMLString:runScript baseURL:url]; +} + +/** + * In order to avoid `UIWebView` thread locks, all JS executions should be + * performed outside of the event loop that notifies the `UIWebViewDelegate` + * that the page has loaded. This is only an issue with the remote debug mode of + * `UIWebView`. For a production `UIWebView` deployment, this delay is + * unnecessary and possibly harmful (or helpful?) + * + * The delay might not be needed as soon as the following change lands into + * iOS7. (Review the patch linked here and search for "crash" + * https://bugs.webkit.org/show_bug.cgi?id=125746). + */ +- (void)executeBlockOnJavaScriptQueue:(dispatch_block_t)block +{ + dispatch_time_t when = dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_MSEC); + + dispatch_after(when, dispatch_get_main_queue(), ^{ + RCTAssertMainThread(); + block(); + }); +} + +/** + * `UIWebViewDelegate` methods. Handle application script load. + */ +- (void)webViewDidFinishLoad:(UIWebView *)webView +{ + RCTAssertMainThread(); + if (_onApplicationScriptLoaded) { + _onApplicationScriptLoaded(nil); // TODO(frantic): how to fetch error from UIWebView? + } + _onApplicationScriptLoaded = nil; +} + +- (void)injectJSONText:(NSString *)script + asGlobalObjectNamed:(NSString *)objectName + callback:(RCTJavaScriptCompleteBlock)onComplete +{ + RCTAssert(!_objectsToInject[objectName], @"already injected object named %@", _objectsToInject[objectName]); + _objectsToInject[objectName] = script; + onComplete(nil); +} +@end diff --git a/ReactKit/Modules/RCTAlertManager.h b/ReactKit/Modules/RCTAlertManager.h new file mode 100644 index 0000000000..e24e0bc95c --- /dev/null +++ b/ReactKit/Modules/RCTAlertManager.h @@ -0,0 +1,9 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import + +#import "RCTBridgeModule.h" + +@interface RCTAlertManager : NSObject + +@end diff --git a/ReactKit/Modules/RCTAlertManager.m b/ReactKit/Modules/RCTAlertManager.m new file mode 100644 index 0000000000..ce5cabea63 --- /dev/null +++ b/ReactKit/Modules/RCTAlertManager.m @@ -0,0 +1,107 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTAlertManager.h" + +#import "RCTLog.h" + +@interface RCTAlertManager() + +@end + +@implementation RCTAlertManager +{ + NSMutableArray *_alerts; + NSMutableArray *_alertCallbacks; + NSMutableArray *_alertButtonKeys; +} + +- (instancetype)init +{ + if ((self = [super init])) { + _alerts = [[NSMutableArray alloc] init]; + _alertCallbacks = [[NSMutableArray alloc] init]; + _alertButtonKeys = [[NSMutableArray alloc] init]; + } + return self; +} + +/** + * @param {NSDictionary} args Dictionary of the form + * + * @{ + * @"message": @"", + * @"buttons": @[ + * @{@"": @""}, + * @{@"": @""}, + * ] + * } + * The key from the `buttons` dictionary is passed back in the callback on click. + * Buttons are displayed in the order they are specified. If "cancel" is used as + * the button key, it will be differently highlighted, according to iOS UI conventions. + */ +- (void)alertWithArgs:(NSDictionary *)args callback:(RCTResponseSenderBlock)callback +{ + RCT_EXPORT(); + + NSString *title = args[@"title"]; + NSString *message = args[@"message"]; + NSArray *buttons = args[@"buttons"]; + + if (!title && !message) { + RCTLogMustFix(@"Must specify either an alert title, or message, or both"); + return; + } else if (buttons.count == 0) { + RCTLogMustFix(@"Must have at least one button."); + return; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + + UIAlertView *alertView = [[UIAlertView alloc] initWithTitle:title + message:message + delegate:self + cancelButtonTitle:nil + otherButtonTitles:nil]; + + NSMutableArray *buttonKeys = [[NSMutableArray alloc] initWithCapacity:buttons.count]; + + NSInteger index = 0; + for (NSDictionary *button in buttons) { + if (button.count != 1) { + RCTLogMustFix(@"Button definitions should have exactly one key."); + } + NSString *buttonKey = [button.allKeys firstObject]; + NSString *buttonTitle = [button[buttonKey] description]; + [alertView addButtonWithTitle:buttonTitle]; + if ([buttonKey isEqualToString: @"cancel"]) { + alertView.cancelButtonIndex = index; + } + [buttonKeys addObject:buttonKey]; + index ++; + } + + [_alerts addObject:alertView]; + [_alertCallbacks addObject:callback ?: ^(id unused) {}]; + [_alertButtonKeys addObject:buttonKeys]; + + [alertView show]; + }); +} + +#pragma mark - UIAlertViewDelegate + +- (void)alertView:(UIAlertView *)alertView clickedButtonAtIndex:(NSInteger)buttonIndex +{ + NSUInteger index = [_alerts indexOfObject:alertView]; + RCTAssert(index != NSNotFound, @"Dismissed alert was not recognised"); + + RCTResponseSenderBlock callback = _alertCallbacks[index]; + NSArray *buttonKeys = _alertButtonKeys[index]; + callback(@[buttonKeys[buttonIndex]]); + + [_alerts removeObjectAtIndex:index]; + [_alertCallbacks removeObjectAtIndex:index]; + [_alertButtonKeys removeObjectAtIndex:index]; +} + +@end diff --git a/ReactKit/Modules/RCTDataManager.h b/ReactKit/Modules/RCTDataManager.h new file mode 100644 index 0000000000..47f80ba1ce --- /dev/null +++ b/ReactKit/Modules/RCTDataManager.h @@ -0,0 +1,10 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import + +#import "RCTBridgeModule.h" + +@interface RCTDataManager : NSObject + +@end + diff --git a/ReactKit/Modules/RCTDataManager.m b/ReactKit/Modules/RCTDataManager.m new file mode 100644 index 0000000000..22872fa910 --- /dev/null +++ b/ReactKit/Modules/RCTDataManager.m @@ -0,0 +1,73 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTDataManager.h" + +#import "RCTAssert.h" +#import "RCTLog.h" +#import "RCTUtils.h" + +@implementation RCTDataManager + +/** + * Executes a network request. + * The responseSender block won't be called on same thread as called. + */ +- (void)executeQuery:(NSString *)queryType + query:(id)query + queryHash:(__unused NSString *)queryHash + responseSender:(RCTResponseSenderBlock)responseSender +{ + RCT_EXPORT(queryData); + + if ([queryType isEqualToString:@"http"]) { + + // Parse query + NSDictionary *queryDict = query; + if ([query isKindOfClass:[NSString class]]) { + // TODO: it would be more efficient just to send a dictionary + queryDict = RCTJSONParse(query, NULL); + } + + // Build request + NSURL *url = [NSURL URLWithString:queryDict[@"url"]]; + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url]; + request.HTTPMethod = queryDict[@"method"] ?: @"GET"; + request.allHTTPHeaderFields = queryDict[@"headers"]; + if ([queryDict[@"data"] isKindOfClass:[NSString class]]) { + request.HTTPBody = [queryDict[@"data"] dataUsingEncoding:NSUTF8StringEncoding]; + } + + // Build data task + NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:request completionHandler:^(NSData *data, NSURLResponse *response, NSError *connectionError) { + + // Build response + NSDictionary *responseJSON; + if (connectionError == nil) { + NSStringEncoding encoding; + if (response.textEncodingName) { + CFStringEncoding cfEncoding = CFStringConvertIANACharSetNameToEncoding((CFStringRef)response.textEncodingName); + encoding = CFStringConvertEncodingToNSStringEncoding(cfEncoding); + } else { + encoding = NSUTF8StringEncoding; + } + NSString *returnData = [[NSString alloc] initWithData:data encoding:encoding]; + responseJSON = @{@"status": @200, @"responseText": returnData}; + } else { + responseJSON = @{@"status": @0, @"responseText": [connectionError localizedDescription]}; + } + + // Send response (won't be sent on same thread as caller) + responseSender(@[RCTJSONStringify(responseJSON, NULL)]); + + }]; + + [task resume]; + + } else { + + RCTLogMustFix(@"unsupported query type %@", queryType); + return; + } +} + +@end diff --git a/ReactKit/Modules/RCTExceptionsManager.h b/ReactKit/Modules/RCTExceptionsManager.h new file mode 100644 index 0000000000..02ea332026 --- /dev/null +++ b/ReactKit/Modules/RCTExceptionsManager.h @@ -0,0 +1,9 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import + +#import "RCTBridgeModule.h" + +@interface RCTExceptionsManager : NSObject + +@end diff --git a/ReactKit/Modules/RCTExceptionsManager.m b/ReactKit/Modules/RCTExceptionsManager.m new file mode 100644 index 0000000000..29cacf6c8a --- /dev/null +++ b/ReactKit/Modules/RCTExceptionsManager.m @@ -0,0 +1,23 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTExceptionsManager.h" + +#import "RCTRedBox.h" + +@implementation RCTExceptionsManager + +- (void)reportUnhandledExceptionWithMessage:(NSString *)message stack:(NSArray *)stack +{ + RCT_EXPORT(reportUnhandledException); + + [[RCTRedBox sharedInstance] showErrorMessage:message withStack:stack]; +} + +- (void)updateExceptionMessage:(NSString *)message stack:(NSArray *)stack +{ + RCT_EXPORT(updateExceptionMessage); + + [[RCTRedBox sharedInstance] updateErrorMessage:message withStack:stack]; +} + +@end diff --git a/ReactKit/Modules/RCTStatusBarManager.h b/ReactKit/Modules/RCTStatusBarManager.h new file mode 100644 index 0000000000..8303935219 --- /dev/null +++ b/ReactKit/Modules/RCTStatusBarManager.h @@ -0,0 +1,9 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import + +#import "RCTBridgeModule.h" + +@interface RCTStatusBarManager : NSObject + +@end diff --git a/ReactKit/Modules/RCTStatusBarManager.m b/ReactKit/Modules/RCTStatusBarManager.m new file mode 100644 index 0000000000..9a49cdd62f --- /dev/null +++ b/ReactKit/Modules/RCTStatusBarManager.m @@ -0,0 +1,67 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTStatusBarManager.h" + +#import "RCTLog.h" + +@implementation RCTStatusBarManager + +static BOOL RCTViewControllerBasedStatusBarAppearance() +{ + static BOOL value; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + value = [[[NSBundle mainBundle] objectForInfoDictionaryKey:@"UIViewControllerBasedStatusBarAppearance"] boolValue]; + }); + + return value; +} + +- (void)setStyle:(UIStatusBarStyle)statusBarStyle animated:(BOOL)animated +{ + RCT_EXPORT(); + + dispatch_async(dispatch_get_main_queue(), ^{ + + if (RCTViewControllerBasedStatusBarAppearance()) { + RCTLogError(@"RCTStatusBarManager module requires that the \ + UIViewControllerBasedStatusBarAppearance key in the Info.plist is set to NO"); + } else { + [[UIApplication sharedApplication] setStatusBarStyle:statusBarStyle + animated:animated]; + } + }); +} + +- (void)setHidden:(BOOL)hidden withAnimation:(UIStatusBarAnimation)animation +{ + RCT_EXPORT(); + + dispatch_async(dispatch_get_main_queue(), ^{ + + if (RCTViewControllerBasedStatusBarAppearance()) { + RCTLogError(@"RCTStatusBarManager module requires that the \ + UIViewControllerBasedStatusBarAppearance key in the Info.plist is set to NO"); + } else { + [[UIApplication sharedApplication] setStatusBarHidden:hidden + withAnimation:animation]; + } + }); +} + ++ (NSDictionary *)constantsToExport +{ + return @{ + @"Style": @{ + @"default": @(UIStatusBarStyleDefault), + @"lightContent": @(UIStatusBarStyleLightContent), + }, + @"Animation": @{ + @"none": @(UIStatusBarAnimationNone), + @"fade": @(UIStatusBarAnimationFade), + @"slide": @(UIStatusBarAnimationSlide), + }, + }; +} + +@end diff --git a/ReactKit/Modules/RCTTiming.h b/ReactKit/Modules/RCTTiming.h new file mode 100644 index 0000000000..aa55c25213 --- /dev/null +++ b/ReactKit/Modules/RCTTiming.h @@ -0,0 +1,10 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import + +#import "RCTBridgeModule.h" +#import "RCTInvalidating.h" + +@interface RCTTiming : NSObject + +@end diff --git a/ReactKit/Modules/RCTTiming.m b/ReactKit/Modules/RCTTiming.m new file mode 100644 index 0000000000..d01b07509b --- /dev/null +++ b/ReactKit/Modules/RCTTiming.m @@ -0,0 +1,203 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTTiming.h" + +#import "RCTBridge.h" +#import "RCTLog.h" +#import "RCTSparseArray.h" +#import "RCTUtils.h" + +@interface RCTTimer : NSObject + +@property (nonatomic, strong, readonly) NSDate *target; +@property (nonatomic, assign, readonly) BOOL repeats; +@property (nonatomic, strong, readonly) NSNumber *callbackID; +@property (nonatomic, assign, readonly) NSTimeInterval interval; + +@end + +@implementation RCTTimer + +- (instancetype)initWithCallbackID:(NSNumber *)callbackID + interval:(NSTimeInterval)interval + targetTime:(NSTimeInterval)targetTime + repeats:(BOOL)repeats +{ + if ((self = [super init])) { + _interval = interval; + _repeats = repeats; + _callbackID = callbackID; + _target = [NSDate dateWithTimeIntervalSinceNow:targetTime]; + } + return self; +} + +/** + * Returns `YES` if we should invoke the JS callback. + */ +- (BOOL)updateFoundNeedsJSUpdate +{ + if (_target && _target.timeIntervalSinceNow <= 0) { + // The JS Timers will do fine grained calculating of expired timeouts. + _target = _repeats ? [NSDate dateWithTimeIntervalSinceNow:_interval] : nil; + return YES; + } + return NO; +} + +@end + +@implementation RCTTiming +{ + RCTSparseArray *_timers; + RCTBridge *_bridge; + id _updateTimer; +} + ++ (NSArray *)JSMethods +{ + return @[@"RCTJSTimers.callTimers"]; +} + +- (instancetype)initWithBridge:(RCTBridge *)bridge +{ + if ((self = [super init])) { + _bridge = bridge; + _timers = [[RCTSparseArray alloc] init]; + [self startTimers]; + + for (NSString *name in @[UIApplicationWillResignActiveNotification, + UIApplicationDidEnterBackgroundNotification, + UIApplicationWillTerminateNotification]) { + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(stopTimers) + name:name + object:nil]; + } + + for (NSString *name in @[UIApplicationDidBecomeActiveNotification, + UIApplicationWillEnterForegroundNotification]) { + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(startTimers) + name:name + object:nil]; + } + } + return self; +} + +- (void)dealloc +{ + [[NSNotificationCenter defaultCenter] removeObserver:self]; +} + +- (BOOL)isValid +{ + return _bridge != nil; +} + +- (void)invalidate +{ + [self stopTimers]; + _bridge = nil; +} + +- (void)stopTimers +{ + [_updateTimer invalidate]; + _updateTimer = nil; +} + +- (void)startTimers +{ + RCTAssertMainThread(); + + if (![self isValid] || _updateTimer != nil) { + return; + } + + _updateTimer = [CADisplayLink displayLinkWithTarget:self selector:@selector(update)]; + if (_updateTimer) { + [_updateTimer addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode]; + } else { + RCTLogWarn(@"Failed to create a display link (probably on buildbot) - using an NSTimer for AppEngine instead."); + _updateTimer = [NSTimer scheduledTimerWithTimeInterval:(1.0 / 60) + target:self + selector:@selector(update) + userInfo:nil + repeats:YES]; + } +} + +- (void)update +{ + RCTAssertMainThread(); + + NSMutableArray *timersToCall = [[NSMutableArray alloc] init]; + for (RCTTimer *timer in _timers.allObjects) { + if ([timer updateFoundNeedsJSUpdate]) { + [timersToCall addObject:timer.callbackID]; + } + if (!timer.target) { + _timers[timer.callbackID] = nil; + } + } + + // call timers that need to be called + if ([timersToCall count] > 0) { + [_bridge enqueueJSCall:@"RCTJSTimers.callTimers" args:@[timersToCall]]; + } +} + +/** + * There's a small difference between the time when we call + * setTimeout/setInterval/requestAnimation frame and the time it actually makes + * it here. This is important and needs to be taken into account when + * calculating the timer's target time. We calculate this by passing in + * Date.now() from JS and then subtracting that from the current time here. + */ +- (void)createTimer:(NSNumber *)callbackID + duration:(double)jsDuration + jsSchedulingTime:(double)jsSchedulingTime + repeats:(BOOL)repeats +{ + RCT_EXPORT(); + + NSTimeInterval interval = jsDuration / 1000; + NSTimeInterval jsCreationTimeSinceUnixEpoch = jsSchedulingTime / 1000; + NSTimeInterval currentTimeSinceUnixEpoch = [[NSDate date] timeIntervalSince1970]; + NSTimeInterval jsSchedulingOverhead = currentTimeSinceUnixEpoch - jsCreationTimeSinceUnixEpoch; + if (jsSchedulingOverhead < 0) { + RCTLogWarn(@"jsSchedulingOverhead (%ims) should be positive", (int)(jsSchedulingOverhead * 1000)); + } + + NSTimeInterval targetTime = interval - jsSchedulingOverhead; + if (interval < 0.018) { // Make sure short intervals run each frame + interval = 0; + } + + RCTTimer *timer = [[RCTTimer alloc] initWithCallbackID:callbackID + interval:interval + targetTime:targetTime + repeats:repeats]; + dispatch_async(dispatch_get_main_queue(), ^{ + _timers[callbackID] = timer; + }); +} + +- (void)deleteTimer:(NSNumber *)timerID +{ + RCT_EXPORT(); + + if (timerID) { + dispatch_async(dispatch_get_main_queue(), ^{ + _timers[timerID] = nil; + }); + } else { + RCTLogWarn(@"Called deleteTimer: with a nil timerID"); + } +} + +@end diff --git a/ReactKit/Modules/RCTUIManager.h b/ReactKit/Modules/RCTUIManager.h new file mode 100644 index 0000000000..87e91118ae --- /dev/null +++ b/ReactKit/Modules/RCTUIManager.h @@ -0,0 +1,26 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import + +#import "RCTBridgeModule.h" +#import "RCTInvalidating.h" + +@class RCTRootView; + +@protocol RCTScrollableProtocol; + +@interface RCTUIManager : NSObject + +@property (nonatomic, weak) id mainScrollView; + +/** + * Allows native environment code to respond to "the main scroll view" events. + * see `RCTUIManager`'s `setMainScrollViewTag`. + */ +@property (nonatomic, readwrite, weak) id nativeMainScrollDelegate; + +- (void)registerRootView:(RCTRootView *)rootView; + ++ (UIView *)JSResponder; + +@end diff --git a/ReactKit/Modules/RCTUIManager.m b/ReactKit/Modules/RCTUIManager.m new file mode 100644 index 0000000000..3b1b564328 --- /dev/null +++ b/ReactKit/Modules/RCTUIManager.m @@ -0,0 +1,1352 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTUIManager.h" + +#import +#import + +#import "Layout.h" +#import "RCTAnimationType.h" +#import "RCTAssert.h" +#import "RCTBridge.h" +#import "RCTConvert.h" +#import "RCTRootView.h" +#import "RCTLog.h" +#import "RCTNavigator.h" +#import "RCTScrollableProtocol.h" +#import "RCTShadowView.h" +#import "RCTSparseArray.h" +#import "RCTUtils.h" +#import "RCTView.h" +#import "RCTViewNodeProtocol.h" +#import "RCTViewManager.h" +#import "UIView+ReactKit.h" + +typedef void (^react_view_node_block_t)(id); + +static void RCTTraverseViewNodes(id view, react_view_node_block_t block) +{ + if (view.reactTag) block(view); + for (id subview in view.reactSubviews) { + RCTTraverseViewNodes(subview, block); + } +} + +static NSDictionary *RCTViewModuleClasses(void) +{ + static NSMutableDictionary *modules; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + modules = [NSMutableDictionary dictionary]; + + unsigned int classCount; + Class *classes = objc_copyClassList(&classCount); + for (unsigned int i = 0; i < classCount; i++) { + + Class cls = classes[i]; + + if (!class_getSuperclass(cls)) { + // Class has no superclass - it's probably something weird + continue; + } + + if (![cls isSubclassOfClass:[RCTViewManager class]]) { + // Not a view module + continue; + } + + // Get module name + NSString *moduleName = [cls moduleName]; + + // Check module name is unique + id existingClass = modules[moduleName]; + RCTCAssert(existingClass == Nil, @"Attempted to register view module class %@ " + "for the name '%@', but name was already registered by class %@", cls, moduleName, existingClass); + + // Add to module list + modules[moduleName] = cls; + } + + free(classes); + }); + + return modules; +} + +@interface RCTAnimation : NSObject + +@property (nonatomic, readonly) NSTimeInterval duration; +@property (nonatomic, readonly) NSTimeInterval delay; +@property (nonatomic, readonly, copy) NSString *property; +@property (nonatomic, readonly) id fromValue; +@property (nonatomic, readonly) id toValue; +@property (nonatomic, readonly) CGFloat springDamping; +@property (nonatomic, readonly) CGFloat initialVelocity; +@property (nonatomic, readonly) RCTAnimationType animationType; + +@end + +@implementation RCTAnimation + +UIViewAnimationCurve UIViewAnimationCurveFromRCTAnimationType(RCTAnimationType type) +{ + switch (type) { + case RCTAnimationTypeLinear: + return UIViewAnimationCurveLinear; + case RCTAnimationTypeEaseIn: + return UIViewAnimationCurveEaseIn; + case RCTAnimationTypeEaseOut: + return UIViewAnimationCurveEaseOut; + case RCTAnimationTypeEaseInEaseOut: + return UIViewAnimationCurveEaseInOut; + default: + RCTCAssert(NO, @"Unsupported animation type %zd", type); + return UIViewAnimationCurveEaseInOut; + } +} + +- (instancetype)initWithDuration:(NSTimeInterval)duration dictionary:(NSDictionary *)config +{ + if (!config) { + return nil; + } + + if ((self = [super init])) { + _property = [RCTConvert NSString:config[@"property"]]; + + // TODO: this should be provided in ms, not seconds + _duration = [RCTConvert NSTimeInterval:config[@"duration"]] ?: duration; + _delay = [RCTConvert NSTimeInterval:config[@"delay"]]; + _animationType = [RCTConvert RCTAnimationType:config[@"type"]]; + if (_animationType == RCTAnimationTypeSpring) { + _springDamping = [RCTConvert CGFloat:config[@"springDamping"]]; + _initialVelocity = [RCTConvert CGFloat:config[@"initialVelocity"]]; + } + _fromValue = config[@"fromValue"]; + _toValue = config[@"toValue"]; + } + return self; +} + +- (void)performAnimations:(void (^)(void))animations + withCompletionBlock:(void (^)(BOOL completed))completionBlock +{ + if (_animationType == RCTAnimationTypeSpring) { + + [UIView animateWithDuration:_duration + delay:_delay + usingSpringWithDamping:_springDamping + initialSpringVelocity:_initialVelocity + options:UIViewAnimationOptionBeginFromCurrentState + animations:animations + completion:completionBlock]; + + } else { + + UIViewAnimationOptions options = UIViewAnimationOptionBeginFromCurrentState | + UIViewAnimationCurveFromRCTAnimationType(_animationType); + + [UIView animateWithDuration:_duration + delay:_delay + options:options + animations:animations + completion:completionBlock]; + } +} + +@end + +@interface RCTLayoutAnimation : NSObject + +@property (nonatomic, strong) RCTAnimation *createAnimation; +@property (nonatomic, strong) RCTAnimation *updateAnimation; +@property (nonatomic, strong) RCTAnimation *deleteAnimation; +@property (nonatomic, strong) RCTResponseSenderBlock callback; + +@end + +@implementation RCTLayoutAnimation + +- (instancetype)initWithDictionary:(NSDictionary *)config callback:(RCTResponseSenderBlock)callback +{ + if (!config) { + return nil; + } + + if ((self = [super init])) { + + // TODO: this should be provided in ms, not seconds + NSTimeInterval duration = [RCTConvert NSTimeInterval:config[@"duration"]]; + + _createAnimation = [[RCTAnimation alloc] initWithDuration:duration dictionary:config[@"create"]]; + _updateAnimation = [[RCTAnimation alloc] initWithDuration:duration dictionary:config[@"update"]]; + _deleteAnimation = [[RCTAnimation alloc] initWithDuration:duration dictionary:config[@"delete"]]; + _callback = callback; + } + return self; +} + +@end + +@implementation RCTUIManager +{ + // Root views are only mutated on the shadow queue + NSMutableSet *_rootViewTags; + NSMutableArray *_pendingUIBlocks; + NSLock *_pendingUIBlocksLock; + + // Animation + RCTLayoutAnimation *_nextLayoutAnimation; // RCT thread only + RCTLayoutAnimation *_layoutAnimation; // Main thread only + + // Keyed by moduleName + NSMutableDictionary *_defaultShadowViews; // RCT thread only + NSMutableDictionary *_defaultViews; // Main thread only + NSDictionary *_viewManagers; + + // Keyed by React tag + RCTSparseArray *_viewManagerRegistry; // RCT thread only + RCTSparseArray *_shadowViewRegistry; // RCT thread only + RCTSparseArray *_viewRegistry; // Main thread only + + __weak RCTBridge *_bridge; +} + +- (instancetype)initWithBridge:(RCTBridge *)bridge +{ + if ((self = [super init])) { + + _bridge = bridge; + _pendingUIBlocksLock = [[NSLock alloc] init]; + + // Instantiate view managers + NSMutableDictionary *viewManagers = [[NSMutableDictionary alloc] init]; + [RCTViewModuleClasses() enumerateKeysAndObjectsUsingBlock:^(NSString *moduleName, Class moduleClass, BOOL *stop) { + viewManagers[moduleName] = [[moduleClass alloc] initWithEventDispatcher:_bridge.eventDispatcher]; + }]; + _viewManagers = viewManagers; + _defaultShadowViews = [[NSMutableDictionary alloc] init]; + _defaultViews = [[NSMutableDictionary alloc] init]; + + _viewManagerRegistry = [[RCTSparseArray alloc] init]; + _shadowViewRegistry = [[RCTSparseArray alloc] init]; + _viewRegistry = [[RCTSparseArray alloc] init]; + + // Internal resources + _pendingUIBlocks = [[NSMutableArray alloc] init]; + _rootViewTags = [[NSMutableSet alloc] init]; + } + return self; +} + +- (instancetype)init +{ + RCT_NOT_DESIGNATED_INITIALIZER(); +} + +- (void)dealloc +{ + RCTAssert(!self.valid, @"must call -invalidate before -dealloc"); +} + +- (BOOL)isValid +{ + return _viewRegistry != nil; +} + +- (void)invalidate +{ + RCTAssertMainThread(); + + _viewRegistry = nil; + _shadowViewRegistry = nil; + + [_pendingUIBlocksLock lock]; + _pendingUIBlocks = nil; + [_pendingUIBlocksLock unlock]; +} + +- (void)registerRootView:(RCTRootView *)rootView; +{ + RCTAssertMainThread(); + + NSNumber *reactTag = rootView.reactTag; + UIView *existingView = _viewRegistry[reactTag]; + RCTCAssert(existingView == nil || existingView == rootView, + @"Expect all root views to have unique tag. Added %@ twice", reactTag); + + // Register view + _viewRegistry[reactTag] = rootView; + CGRect frame = rootView.frame; + + // Register manager (TODO: should we do this, or leave it nil?) + _viewManagerRegistry[reactTag] = _viewManagers[[RCTViewManager moduleName]]; + + // Register shadow view + dispatch_async(_bridge.shadowQueue, ^{ + + RCTShadowView *shadowView = [[RCTShadowView alloc] init]; + shadowView.reactTag = reactTag; + shadowView.frame = frame; + shadowView.backgroundColor = [UIColor whiteColor]; + shadowView.reactRootView = YES; // can this just be inferred from the fact that it has no superview? + _shadowViewRegistry[shadowView.reactTag] = shadowView; + + [_rootViewTags addObject:reactTag]; + }); +} + +/** + * Unregisters views from registries + */ +- (void)_purgeChildren:(NSArray *)children fromRegistry:(RCTSparseArray *)registry +{ + for (id child in children) { + RCTTraverseViewNodes(registry[child.reactTag], ^(id subview) { + RCTAssert(![subview isReactRootView], @"Root views should not be unregistered"); + if ([subview conformsToProtocol:@protocol(RCTInvalidating)]) { + [(id)subview invalidate]; + } + registry[subview.reactTag] = nil; + }); + } +} + +- (void)addUIBlock:(RCTViewManagerUIBlock)block +{ + RCTAssert(![NSThread isMainThread], @"This method should only be called on the shadow thread"); + + __weak RCTUIManager *weakViewManager = self; + __weak RCTSparseArray *weakViewRegistry = _viewRegistry; + dispatch_block_t outerBlock = ^{ + RCTUIManager *strongViewManager = weakViewManager; + RCTSparseArray *strongViewRegistry = weakViewRegistry; + if (strongViewManager && strongViewRegistry) { + block(strongViewManager, strongViewRegistry); + } + }; + + [_pendingUIBlocksLock lock]; + [_pendingUIBlocks addObject:outerBlock]; + [_pendingUIBlocksLock unlock]; +} + +- (RCTViewManagerUIBlock)uiBlockWithLayoutUpdateForRootView:(RCTShadowView *)rootShadowView +{ + NSMutableSet *viewsWithNewFrames = [NSMutableSet setWithCapacity:1]; + + // This is nuanced. In the JS thread, we create a new update buffer + // `frameTags`/`frames` that is created/mutated in the JS thread. We access + // these structures in the UI-thread block. `NSMutableArray` is not thread + // safe so we rely on the fact that we never mutate it after it's passed to + // the main thread. + [rootShadowView collectRootUpdatedFrames:viewsWithNewFrames parentConstraint:(CGSize){CSS_UNDEFINED, CSS_UNDEFINED}]; + + // Parallel arrays + NSMutableArray *frameReactTags = [NSMutableArray arrayWithCapacity:viewsWithNewFrames.count]; + NSMutableArray *frames = [NSMutableArray arrayWithCapacity:viewsWithNewFrames.count]; + NSMutableArray *areNew = [NSMutableArray arrayWithCapacity:viewsWithNewFrames.count]; + NSMutableArray *parentsAreNew = [NSMutableArray arrayWithCapacity:viewsWithNewFrames.count]; + + for (RCTShadowView *shadowView in viewsWithNewFrames) { + [frameReactTags addObject:shadowView.reactTag]; + [frames addObject:[NSValue valueWithCGRect:shadowView.frame]]; + [areNew addObject:@(shadowView.isNewView)]; + [parentsAreNew addObject:@(shadowView.superview.isNewView)]; + } + + for (RCTShadowView *shadowView in viewsWithNewFrames) { + // We have to do this after we build the parentsAreNew array. + shadowView.newView = NO; + } + + // Perform layout (possibly animated) + NSNumber *rootViewTag = rootShadowView.reactTag; + return ^(RCTUIManager *uiManager, RCTSparseArray *viewRegistry) { + for (NSUInteger ii = 0; ii < frames.count; ii++) { + NSNumber *reactTag = frameReactTags[ii]; + UIView *view = viewRegistry[reactTag]; + CGRect frame = [frames[ii] CGRectValue]; + + // These frames are in terms of anchorPoint = topLeft, but internally the + // views are anchorPoint = center for easier scale and rotation animations. + // Convert the frame so it works with anchorPoint = center. + CGPoint position = {CGRectGetMidX(frame), CGRectGetMidY(frame)}; + CGRect bounds = {0, 0, frame.size}; + + // Avoid crashes due to nan coords + if (isnan(position.x) || isnan(position.y) || + isnan(bounds.origin.x) || isnan(bounds.origin.y) || + isnan(bounds.size.width) || isnan(bounds.size.height)) { + RCTLogError(@"Invalid layout for (%zd)%@. position: %@. bounds: %@", [view reactTag], self, NSStringFromCGPoint(position), NSStringFromCGRect(bounds)); + continue; + } + + void (^completion)(BOOL finished) = ^(BOOL finished) { + if (self->_layoutAnimation.callback) { + self->_layoutAnimation.callback(@[@(finished)]); + } + }; + + // Animate view update + BOOL isNew = [areNew[ii] boolValue]; + RCTAnimation *updateAnimation = isNew ? nil: _layoutAnimation.updateAnimation; + if (updateAnimation) { + [updateAnimation performAnimations:^{ + view.layer.position = position; + view.layer.bounds = bounds; + } withCompletionBlock:completion]; + } else { + view.layer.position = position; + view.layer.bounds = bounds; + completion(YES); + } + + // Animate view creations + BOOL shouldAnimateCreation = isNew && ![parentsAreNew[ii] boolValue]; + RCTAnimation *createAnimation = _layoutAnimation.createAnimation; + if (shouldAnimateCreation && createAnimation) { + if ([createAnimation.property isEqualToString:@"scaleXY"]) { + view.layer.transform = CATransform3DMakeScale(0, 0, 0); + } else if ([createAnimation.property isEqualToString:@"opacity"]) { + view.layer.opacity = 0.0; + } + [createAnimation performAnimations:^{ + if ([createAnimation.property isEqual:@"scaleXY"]) { + view.layer.transform = CATransform3DIdentity; + } else if ([createAnimation.property isEqual:@"opacity"]) { + view.layer.opacity = 1.0; + } else { + RCTLogError(@"Unsupported layout animation createConfig property %@", createAnimation.property); + } + } withCompletionBlock:nil]; + } + } + + RCTRootView *rootView = _viewRegistry[rootViewTag]; + RCTTraverseViewNodes(rootView, ^(id view) { + if ([view respondsToSelector:@selector(reactBridgeDidFinishTransaction)]) { + [view reactBridgeDidFinishTransaction]; + } + }); + }; +} + +- (void)_amendPendingUIBlocksWithStylePropagationUpdateForRootView:(RCTShadowView *)topView +{ + NSMutableSet *applierBlocks = [NSMutableSet setWithCapacity:1]; + [topView collectUpdatedProperties:applierBlocks parentProperties:@{}]; + + [self addUIBlock:^(RCTUIManager *uiManager, RCTSparseArray *viewRegistry) { + for (RCTApplierBlock block in applierBlocks) { + block(viewRegistry); + } + }]; +} + +/** + * A method to be called from JS, which takes a container ID and then releases + * all subviews for that container upon receipt. + */ +- (void)removeSubviewsFromContainerWithID:(NSNumber *)containerID +{ + RCT_EXPORT(); + + id container = _viewRegistry[containerID]; + RCTAssert(container != nil, @"container view (for ID %@) not found", containerID); + + NSUInteger subviewsCount = [[container reactSubviews] count]; + NSMutableArray *indices = [[NSMutableArray alloc] initWithCapacity:subviewsCount]; + for (NSInteger childIndex = 0; childIndex < subviewsCount; childIndex++) { + [indices addObject:@(childIndex)]; + } + + [self manageChildren:containerID + moveFromIndices:nil + moveToIndices:nil + addChildReactTags:nil + addAtIndices:nil + removeAtIndices:indices]; +} + +/** + * Disassociates children from container. Doesn't remove from registries. + * TODO: use [NSArray getObjects:buffer] to reuse same fast buffer each time. + * + * @returns Array of removed items. + */ +- (NSArray *)_childrenToRemoveFromContainer:(id)container + atIndices:(NSArray *)atIndices +{ + // If there are no indices to move or the container has no subviews don't bother + // We support parents with nil subviews so long as they're all nil so this allows for this behavior + if ([atIndices count] == 0 || [[container reactSubviews] count] == 0) { + return nil; + } + // Construction of removed children must be done "up front", before indices are disturbed by removals. + NSMutableArray *removedChildren = [NSMutableArray arrayWithCapacity:atIndices.count]; + RCTCAssert(container != nil, @"container view (for ID %@) not found", container); + for (NSInteger i = 0; i < [atIndices count]; i++) { + NSInteger index = [atIndices[i] integerValue]; + if (index < [[container reactSubviews] count]) { + [removedChildren addObject:[container reactSubviews][index]]; + } + } + if (removedChildren.count != atIndices.count) { + RCTLogMustFix(@"removedChildren count (%tu) was not what we expected (%tu)", removedChildren.count, atIndices.count); + } + return removedChildren; +} + +- (void)_removeChildren:(NSArray *)children fromContainer:(id)container +{ + for (id removedChild in children) { + [container removeReactSubview:removedChild]; + } +} + +- (void)removeRootView:(NSNumber *)rootReactTag +{ + RCT_EXPORT(); + + RCTShadowView *rootShadowView = _shadowViewRegistry[rootReactTag]; + RCTAssert(rootShadowView.superview == nil, @"root view cannot have superview (ID %@)", rootReactTag); + [self _purgeChildren:@[rootShadowView] fromRegistry:_shadowViewRegistry]; + [_rootViewTags removeObject:rootReactTag]; + + [self addUIBlock:^(RCTUIManager *uiManager, RCTSparseArray *viewRegistry){ + RCTCAssertMainThread(); + UIView *rootView = viewRegistry[rootReactTag]; + [uiManager _purgeChildren:@[rootView] fromRegistry:viewRegistry]; + }]; +} + +- (void)replaceExistingNonRootView:(NSNumber *)reactTag withView:(NSNumber *)newReactTag +{ + RCT_EXPORT(); + + RCTShadowView *shadowView = _shadowViewRegistry[reactTag]; + RCTAssert(shadowView != nil, @"shadowView (for ID %@) not found", reactTag); + + RCTShadowView *superShadowView = shadowView.superview; + RCTAssert(superShadowView != nil, @"shadowView super (of ID %@) not found", reactTag); + + NSUInteger indexOfView = [superShadowView.reactSubviews indexOfObject:shadowView]; + RCTAssert(indexOfView != NSNotFound, @"View's superview doesn't claim it as subview (id %@)", reactTag); + NSArray *removeAtIndices = @[@(indexOfView)]; + NSArray *addTags = @[newReactTag]; + [self manageChildren:superShadowView.reactTag + moveFromIndices:nil + moveToIndices:nil + addChildReactTags:addTags + addAtIndices:removeAtIndices + removeAtIndices:removeAtIndices]; +} + +- (void)manageChildren:(NSNumber *)containerReactTag + moveFromIndices:(NSArray *)moveFromIndices + moveToIndices:(NSArray *)moveToIndices + addChildReactTags:(NSArray *)addChildReactTags + addAtIndices:(NSArray *)addAtIndices + removeAtIndices:(NSArray *)removeAtIndices +{ + RCT_EXPORT(); + + [self _manageChildren:containerReactTag + moveFromIndices:moveFromIndices + moveToIndices:moveToIndices + addChildReactTags:addChildReactTags + addAtIndices:addAtIndices + removeAtIndices:removeAtIndices + registry:_shadowViewRegistry]; + + [self addUIBlock:^(RCTUIManager *uiManager, RCTSparseArray *viewRegistry){ + + [uiManager _manageChildren:containerReactTag + moveFromIndices:moveFromIndices + moveToIndices:moveToIndices + addChildReactTags:addChildReactTags + addAtIndices:addAtIndices + removeAtIndices:removeAtIndices + registry:viewRegistry]; + }]; +} + +- (void)_manageChildren:(NSNumber *)containerReactTag + moveFromIndices:(NSArray *)moveFromIndices + moveToIndices:(NSArray *)moveToIndices + addChildReactTags:(NSArray *)addChildReactTags + addAtIndices:(NSArray *)addAtIndices + removeAtIndices:(NSArray *)removeAtIndices + registry:(RCTSparseArray *)registry +{ + id container = registry[containerReactTag]; + RCTAssert(moveFromIndices.count == moveToIndices.count, @"moveFromIndices had size %tu, moveToIndices had size %tu", moveFromIndices.count, moveToIndices.count); + RCTAssert(addChildReactTags.count == addAtIndices.count, @"there should be at least one react child to add"); + + // Removes (both permanent and temporary moves) are using "before" indices + NSArray *permanentlyRemovedChildren = [self _childrenToRemoveFromContainer:container atIndices:removeAtIndices]; + NSArray *temporarilyRemovedChildren = [self _childrenToRemoveFromContainer:container atIndices:moveFromIndices]; + [self _removeChildren:permanentlyRemovedChildren fromContainer:container]; + [self _removeChildren:temporarilyRemovedChildren fromContainer:container]; + + [self _purgeChildren:permanentlyRemovedChildren fromRegistry:registry]; + + // TODO (#5906496): optimize all these loops - constantly calling array.count is not efficient + + // Figure out what to insert - merge temporary inserts and adds + NSMutableDictionary *destinationsToChildrenToAdd = [NSMutableDictionary dictionary]; + for (NSInteger index = 0; index < temporarilyRemovedChildren.count; index++) { + destinationsToChildrenToAdd[moveToIndices[index]] = temporarilyRemovedChildren[index]; + } + for (NSInteger index = 0; index < addAtIndices.count; index++) { + id view = registry[addChildReactTags[index]]; + if (view) { + destinationsToChildrenToAdd[addAtIndices[index]] = view; + } + } + + NSArray *sortedIndices = [[destinationsToChildrenToAdd allKeys] sortedArrayUsingSelector:@selector(compare:)]; + for (NSNumber *reactIndex in sortedIndices) { + [container insertReactSubview:destinationsToChildrenToAdd[reactIndex] atIndex:[reactIndex integerValue]]; + } +} + +static BOOL RCTCallPropertySetter(SEL setter, id value, id view, id defaultView, RCTViewManager *manager) +{ + // TODO: cache respondsToSelector tests + if ([manager respondsToSelector:setter]) { + + if (value == [NSNull null]) { + value = nil; + } + + ((void (*)(id, SEL, id, id, id))objc_msgSend)(manager, setter, value, view, defaultView); + return YES; + } + return NO; +} + +static void RCTSetViewProps(NSDictionary *props, UIView *view, + UIView *defaultView, RCTViewManager *manager) +{ + [props enumerateKeysAndObjectsUsingBlock:^(NSString *key, id obj, BOOL *stop) { + + SEL setter = NSSelectorFromString([NSString stringWithFormat:@"set_%@:forView:withDefaultView:", key]); + + // For regular views we don't attempt to set properties + // unless the view property has been explicitly exported. + RCTCallPropertySetter(setter, obj, view, defaultView, manager); + }]; +} + +static void RCTSetShadowViewProps(NSDictionary *props, RCTShadowView *shadowView, + RCTShadowView *defaultView, RCTViewManager *manager) +{ + [props enumerateKeysAndObjectsUsingBlock:^(NSString *key, id obj, BOOL *stop) { + + SEL setter = NSSelectorFromString([NSString stringWithFormat:@"set_%@:forShadowView:withDefaultView:", key]); + + // For shadow views we call any custom setter methods by default, + // but if none is specified, we attempt to set property anyway. + if (!RCTCallPropertySetter(setter, obj, shadowView, defaultView, manager)) { + + if (obj == [NSNull null]) { + // Copy property from default view to current + // Note: not just doing `[defaultView valueForKey:key]`, the + // key may not exist, in which case we'd get an exception. + RCTCopyProperty(shadowView, defaultView, key); + } else { + RCTSetProperty(shadowView, key, obj); + } + } + }]; + + // Update layout + [shadowView updateShadowViewLayout]; +} + +- (void)createAndRegisterViewWithReactTag:(NSNumber *)reactTag + moduleName:(NSString *)moduleName + props:(NSDictionary *)props +{ + RCT_EXPORT(createView); + + RCTViewManager *manager = _viewManagers[moduleName]; + if (manager == nil) { + RCTLogWarn(@"No manager class found for view with module name \"%@\"", moduleName); + manager = [[RCTViewManager alloc] init]; + } + + // Register manager + _viewManagerRegistry[reactTag] = manager; + + // Generate default view, used for resetting default props + if (!_defaultShadowViews[moduleName]) { + _defaultShadowViews[moduleName] = [manager shadowView]; + } + + RCTShadowView *shadowView = [manager shadowView]; + shadowView.moduleName = moduleName; + shadowView.reactTag = reactTag; + RCTSetShadowViewProps(props, shadowView, _defaultShadowViews[moduleName], manager); + _shadowViewRegistry[shadowView.reactTag] = shadowView; + + [self addUIBlock:^(RCTUIManager *uiManager, RCTSparseArray *viewRegistry){ + RCTCAssertMainThread(); + + // Generate default view, used for resetting default props + if (!uiManager->_defaultViews[moduleName]) { + // Note the default is setup after the props are read for the first time ever + // for this className - this is ok because we only use the default for restoring + // defaults, which never happens on first creation. + uiManager->_defaultViews[moduleName] = [manager view]; + } + + UIView *view = [manager view]; + if (view) { + + // Set required properties + view.reactTag = reactTag; + view.multipleTouchEnabled = YES; + view.userInteractionEnabled = YES; // required for touch handling + view.layer.allowsGroupOpacity = YES; // required for touch handling + + // Set custom properties + RCTSetViewProps(props, view, uiManager->_defaultViews[moduleName], manager); + } + viewRegistry[view.reactTag] = view; + }]; +} + +// TODO: remove moduleName param as it isn't needed +- (void)updateView:(NSNumber *)reactTag moduleName:(__unused NSString *)_ props:(NSDictionary *)props +{ + RCT_EXPORT(); + + RCTViewManager *viewManager = _viewManagerRegistry[reactTag]; + NSString *moduleName = [[viewManager class] moduleName]; + + RCTShadowView *shadowView = _shadowViewRegistry[reactTag]; + RCTSetShadowViewProps(props, shadowView, _defaultShadowViews[moduleName], viewManager); + + [self addUIBlock:^(RCTUIManager *uiManager, RCTSparseArray *viewRegistry) { + UIView *view = uiManager->_viewRegistry[reactTag]; + RCTSetViewProps(props, view, uiManager->_defaultViews[moduleName], viewManager); + }]; +} + +- (void)becomeResponder:(NSNumber *)reactTag +{ + RCT_EXPORT(focus); + + if (!reactTag) return; + [self addUIBlock:^(RCTUIManager *uiManager, RCTSparseArray *viewRegistry) { + UIView *newResponder = viewRegistry[reactTag]; + [newResponder becomeFirstResponder]; + }]; +} + +- (void)resignResponder:(NSNumber *)reactTag +{ + RCT_EXPORT(blur); + + if (!reactTag) return; + [self addUIBlock:^(RCTUIManager *uiManager, RCTSparseArray *viewRegistry){ + UIView *currentResponder = viewRegistry[reactTag]; + [currentResponder resignFirstResponder]; + }]; +} + +- (void)batchDidComplete +{ + // First copy the previous blocks into a temporary variable, then reset the + // pending blocks to a new array. This guards against mutation while + // processing the pending blocks in another thread. + for (RCTViewManager *manager in _viewManagers.allValues) { + RCTViewManagerUIBlock uiBlock = [manager uiBlockToAmendWithShadowViewRegistry:_shadowViewRegistry]; + if (uiBlock) { + [self addUIBlock:uiBlock]; + } + } + + // Set up next layout animation + if (_nextLayoutAnimation) { + RCTLayoutAnimation *layoutAnimation = _nextLayoutAnimation; + [self addUIBlock:^(RCTUIManager *uiManager, RCTSparseArray *viewRegistry) { + uiManager->_layoutAnimation = layoutAnimation; + }]; + } + + // Perform layout + for (NSNumber *reactTag in _rootViewTags) { + RCTShadowView *rootView = _shadowViewRegistry[reactTag]; + [self addUIBlock:[self uiBlockWithLayoutUpdateForRootView:rootView]]; + [self _amendPendingUIBlocksWithStylePropagationUpdateForRootView:rootView]; + } + + // Clear layout animations + if (_nextLayoutAnimation) { + [self addUIBlock:^(RCTUIManager *uiManager, RCTSparseArray *viewRegistry) { + uiManager->_layoutAnimation = nil; + }]; + _nextLayoutAnimation = nil; + } + + [_pendingUIBlocksLock lock]; + NSArray *previousPendingUIBlocks = _pendingUIBlocks; + _pendingUIBlocks = [[NSMutableArray alloc] init]; + [_pendingUIBlocksLock unlock]; + + dispatch_async(dispatch_get_main_queue(), ^{ + for (dispatch_block_t block in previousPendingUIBlocks) { + block(); + } + }); +} + +- (void)measure:(NSNumber *)reactTag callback:(RCTResponseSenderBlock)callback +{ + RCT_EXPORT(); + + if (!callback) { + RCTLogError(@"Called measure with no callback"); + return; + } + + [self addUIBlock:^(RCTUIManager *uiManager, RCTSparseArray *viewRegistry) { + UIView *view = viewRegistry[reactTag]; + if (!view) { + RCTLogError(@"measure cannot find view with tag %zd", reactTag); + return; + } + CGRect frame = view.frame; + + UIView *rootView = view; + while (rootView && ![rootView isReactRootView]) { + rootView = rootView.superview; + } + + RCTCAssert([rootView isReactRootView], @"React view not inside RCTRootView"); + + // By convention, all coordinates, whether they be touch coordinates, or + // measurement coordinates are with respect to the root view. + CGPoint pagePoint = [view.superview convertPoint:frame.origin toView:rootView]; + + callback(@[ + @(frame.origin.x), + @(frame.origin.y), + @(frame.size.width), + @(frame.size.height), + @(pagePoint.x), + @(pagePoint.y) + ]); + }]; +} + +- (void)requestSchedulingJavaScriptNavigation:(NSNumber *)reactTag + errorCallback:(RCTResponseSenderBlock)errorCallback + callback:(RCTResponseSenderBlock)callback +{ + RCT_EXPORT(); + + if (!callback || !errorCallback) { + RCTLogError(@"Callback not provided for navigation scheduling."); + return; + } + [self addUIBlock:^(RCTUIManager *uiManager, RCTSparseArray *viewRegistry){ + if (reactTag) { + //TODO: This is nasty - why is RCTNavigator hard-coded? + id rkObject = viewRegistry[reactTag]; + if ([rkObject isKindOfClass:[RCTNavigator class]]) { + RCTNavigator *navigator = (RCTNavigator *)rkObject; + BOOL wasAcquired = [navigator requestSchedulingJavaScriptNavigation]; + callback(@[@(wasAcquired)]); + } else { + NSString *msg = + [NSString stringWithFormat: @"Cannot set lock: Tag %@ is not an RCTNavigator", reactTag]; + errorCallback(@[RCTAPIErrorObject(msg)]); + } + } else { + NSString *msg = [NSString stringWithFormat: @"Tag not specified for requestSchedulingJavaScriptNavigation"]; + errorCallback(@[RCTAPIErrorObject(msg)]); + } + }]; +} + + +/** + * TODO: This could be modified to accept any `RCTViewNodeProtocol`, if + * appropriate changes were made to that protocol to support `superview` + * traversal - which is possibly more difficult than it sounds since a + * `superview` is not a "react superview". + */ ++ (void)measureLayoutOnNodes:(RCTShadowView *)view + ancestor:(RCTShadowView *)ancestor + errorCallback:(RCTResponseSenderBlock)errorCallback + callback:(RCTResponseSenderBlock)callback +{ + if (!view) { + NSString *msg = [NSString stringWithFormat: @"Attempting to measure view that does not exist %@", view]; + errorCallback(@[RCTAPIErrorObject(msg)]); + return; + } + if (!ancestor) { + NSString *msg = [NSString stringWithFormat: @"Attempting to measure relative to ancestor that does not exist %@", ancestor]; + errorCallback(@[RCTAPIErrorObject(msg)]); + return; + } + CGRect result = [RCTShadowView measureLayout:view relativeTo:ancestor]; + if (CGRectIsNull(result)) { + NSString *msg = [NSString stringWithFormat: @"view %@ is not an decendant of %@", view, ancestor]; + errorCallback(@[RCTAPIErrorObject(msg)]); + return; + } + CGFloat leftOffset = result.origin.x; + CGFloat topOffset = result.origin.y; + CGFloat width = result.size.width; + CGFloat height = result.size.height; + if (isnan(leftOffset) || isnan(topOffset) || isnan(width) || isnan(height)) { + errorCallback(@[RCTAPIErrorObject(@"Attempted to measure layout but offset or dimensions were NaN")]); + return; + } + callback(@[@(topOffset), @(leftOffset), @(width), @(height)]); +} + +/** + * Returns the computed recursive offset layout in a dictionary form. The + * returned values are relative to the `ancestor` shadow view. Returns `nil`, if + * the `ancestor` shadow view is not actually an `ancestor`. Does not touch + * anything on the main UI thread. Invokes supplied callback with (x, y, width, + * height). + */ +- (void)measureLayout:(NSNumber *)reactTag + relativeTo:(NSNumber *)ancestorReactTag + errorCallback:(RCTResponseSenderBlock)errorCallback + callback:(RCTResponseSenderBlock)callback +{ + RCT_EXPORT(); + + RCTShadowView *shadowView = _shadowViewRegistry[reactTag]; + RCTShadowView *ancestorShadowView = _shadowViewRegistry[ancestorReactTag]; + [RCTUIManager measureLayoutOnNodes:shadowView ancestor:ancestorShadowView errorCallback:errorCallback callback:callback]; +} + +/** + * Returns the computed recursive offset layout in a dictionary form. The + * returned values are relative to the `ancestor` shadow view. Returns `nil`, if + * the `ancestor` shadow view is not actually an `ancestor`. Does not touch + * anything on the main UI thread. Invokes supplied callback with (x, y, width, + * height). + */ +- (void)measureLayoutRelativeToParent:(NSNumber *)reactTag + errorCallback:(RCTResponseSenderBlock)errorCallback + callback:(RCTResponseSenderBlock)callback +{ + RCT_EXPORT(); + + RCTShadowView *shadowView = _shadowViewRegistry[reactTag]; + [RCTUIManager measureLayoutOnNodes:shadowView ancestor:[shadowView superview] errorCallback:errorCallback callback:callback]; +} + +/** + * Returns an array of computed offset layouts in a dictionary form. The layouts are of any react subviews + * that are immediate descendants to the parent view found within a specified rect. The dictionary result + * contains left, top, width, height and an index. The index specifies the position among the other subviews. + * Only layouts for views that are within the rect passed in are returned. Invokes the error callback if the + * passed in parent view does not exist. Invokes the supplied callback with the array of computed layouts. + */ +- (void)measureViewsInRect:(NSDictionary *)rect + parentView:(NSNumber *)reactTag + errorCallback:(RCTResponseSenderBlock)errorCallback + callback:(RCTResponseSenderBlock)callback +{ + RCT_EXPORT(); + + RCTShadowView *shadowView = _shadowViewRegistry[reactTag]; + if (!shadowView) { + NSString *msg = [NSString stringWithFormat: @"Attempting to measure view that does not exist %@", shadowView]; + errorCallback(@[RCTAPIErrorObject(msg)]); + return; + } + NSArray *childShadowViews = [shadowView reactSubviews]; + NSMutableArray *results = [[NSMutableArray alloc] initWithCapacity:[childShadowViews count]]; + CGRect layoutRect = [RCTConvert CGRect:rect]; + + for (int ii = 0; ii < [childShadowViews count]; ii++) { + RCTShadowView *childShadowView = [childShadowViews objectAtIndex:ii]; + CGRect childLayout = [RCTShadowView measureLayout:childShadowView relativeTo:shadowView]; + if (CGRectIsNull(childLayout)) { + NSString *msg = [NSString stringWithFormat: @"view %@ is not a decendant of %@", childShadowView, shadowView]; + errorCallback(@[RCTAPIErrorObject(msg)]); + return; + } + + CGFloat leftOffset = childLayout.origin.x; + CGFloat topOffset = childLayout.origin.y; + CGFloat width = childLayout.size.width; + CGFloat height = childLayout.size.height; + + if (leftOffset <= layoutRect.origin.x + layoutRect.size.width && + leftOffset + width >= layoutRect.origin.x && + topOffset <= layoutRect.origin.y + layoutRect.size.height && + topOffset + height >= layoutRect.origin.y) { + // This view is within the layout rect + NSDictionary *result = @{@"index": @(ii), + @"left": @(leftOffset), + @"top": @(topOffset), + @"width": @(width), + @"height": @(height)}; + + [results addObject:result]; + } + } + callback(@[results]); +} + +- (void)setMainScrollViewTag:(NSNumber *)reactTag +{ + RCT_EXPORT(); + + [self addUIBlock:^(RCTUIManager *uiManager, RCTSparseArray *viewRegistry){ + // - There should be at most one designated "main scroll view" + // - There should be at most one designated "`nativeMainScrollDelegate`" + // - The one designated main scroll view should have the one designated + // `nativeMainScrollDelegate` set as its `nativeMainScrollDelegate`. + if (uiManager.mainScrollView) { + uiManager.mainScrollView.nativeMainScrollDelegate = nil; + } + if (reactTag) { + id rkObject = viewRegistry[reactTag]; + if ([rkObject conformsToProtocol:@protocol(RCTScrollableProtocol)]) { + uiManager.mainScrollView = (id)rkObject; + ((id)rkObject).nativeMainScrollDelegate = uiManager.nativeMainScrollDelegate; + } else { + RCTCAssert(NO, @"Tag %@ does not conform to RCTScrollableProtocol", reactTag); + } + } else { + uiManager.mainScrollView = nil; + } + }]; +} + +- (void)scrollToOffsetWithView:(NSNumber *)reactTag scrollToOffsetX:(NSNumber *)offsetX offsetY:(NSNumber *)offsetY +{ + RCT_EXPORT(scrollTo); + + [self addUIBlock:^(RCTUIManager *uiManager, RCTSparseArray *viewRegistry){ + UIView *view = viewRegistry[reactTag]; + if ([view conformsToProtocol:@protocol(RCTScrollableProtocol)]) { + [(id)view scrollToOffset:CGPointMake([offsetX floatValue], [offsetY floatValue])]; + } else { + RCTLogError(@"tried to scrollToOffset: on non-RCTScrollableProtocol view %@ with tag %@", view, reactTag); + } + }]; +} + +- (void)zoomToRectWithView:(NSNumber *)reactTag rect:(NSDictionary *)rectDict +{ + RCT_EXPORT(zoomToRect); + + [self addUIBlock:^(RCTUIManager *uiManager, RCTSparseArray *viewRegistry){ + UIView *view = viewRegistry[reactTag]; + if ([view conformsToProtocol:@protocol(RCTScrollableProtocol)]) { + [(id)view zoomToRect:[RCTConvert CGRect:rectDict] animated:YES]; + } else { + RCTLogError(@"tried to zoomToRect: on non-RCTScrollableProtocol view %@ with tag %@", view, reactTag); + } + }]; +} + +- (void)getScrollViewContentSize:(NSNumber *)reactTag callback:(RCTResponseSenderBlock)callback failCallback:(RCTResponseSenderBlock)failCallback +{ + RCT_EXPORT(); + + [self addUIBlock:^(RCTUIManager *uiManager, RCTSparseArray *viewRegistry) { + UIView *view = viewRegistry[reactTag]; + if (!view) { + NSString *error = [[NSString alloc] initWithFormat:@"cannot find view with tag %@", reactTag]; + RCTLogError(@"%@", error); + failCallback(@[@{@"error": error}]); + return; + } + + CGSize size = ((id)view).contentSize; + NSDictionary *dict = @{@"width" : @(size.width), @"height" : @(size.height)}; + callback(@[dict]); + }]; +} + +/** + * JS sets what *it* considers to be the responder. Later, scroll views can use + * this in order to determine if scrolling is appropriate. + */ +- (void)setJSResponder:(NSNumber *)reactTag +{ + RCT_EXPORT(); + + [self addUIBlock:^(RCTUIManager *uiManager, RCTSparseArray *viewRegistry) { + _jsResponder = viewRegistry[reactTag]; + if (!_jsResponder) { + RCTLogMustFix(@"Invalid view set to be the JS responder - tag %zd", reactTag); + } + }]; +} + +- (void)clearJSResponder +{ + RCT_EXPORT(); + + [self addUIBlock:^(RCTUIManager *uiManager, RCTSparseArray *viewRegistry) { + _jsResponder = nil; + }]; +} + ++ (NSDictionary *)allBubblingEventTypesConfigs +{ + NSMutableDictionary *customBubblingEventTypesConfigs = [@{ + // Bubble dispatched events + @"topTap": @{ + @"phasedRegistrationNames": @{ + @"bubbled": @"notActuallyTapDontUseMe", + @"captured": @"notActuallyTapCaptureDontUseMe" + } + }, + @"topVisibleCellsChange": @{ + @"phasedRegistrationNames": @{ + @"bubbled": @"onVisibleCellsChange", + @"captured": @"onVisibleCellsChangeCapture" + } + }, + @"topNavigateBack": @{ + @"phasedRegistrationNames": @{ + @"bubbled": @"onNavigationComplete", + @"captured": @"onNavigationCompleteCapture" + } + }, + @"topNavRightButtonTap": @{ + @"phasedRegistrationNames": @{ + @"bubbled": @"onNavRightButtonTap", + @"captured": @"onNavRightButtonTapCapture" + } + }, + @"topChange": @{ + @"phasedRegistrationNames": @{ + @"bubbled": @"onChange", + @"captured": @"onChangeCapture" + } + }, + @"topFocus": @{ + @"phasedRegistrationNames": @{ + @"bubbled": @"onFocus", + @"captured": @"onFocusCapture" + } + }, + @"topBlur": @{ + @"phasedRegistrationNames": @{ + @"bubbled": @"onBlur", + @"captured": @"onBlurCapture" + } + }, + @"topSubmitEditing": @{ + @"phasedRegistrationNames": @{ + @"bubbled": @"onSubmitEditing", + @"captured": @"onSubmitEditingCapture" + } + }, + @"topEndEditing": @{ + @"phasedRegistrationNames": @{ + @"bubbled": @"onEndEditing", + @"captured": @"onEndEditingCapture" + } + }, + @"topTextInput": @{ + @"phasedRegistrationNames": @{ + @"bubbled": @"onTextInput", + @"captured": @"onTextInputCapture" + } + }, + @"topTouchStart": @{ + @"phasedRegistrationNames": @{ + @"bubbled": @"onTouchStart", + @"captured": @"onTouchStartCapture" + } + }, + @"topTouchMove": @{ + @"phasedRegistrationNames": @{ + @"bubbled": @"onTouchMove", + @"captured": @"onTouchMoveCapture" + } + }, + @"topTouchCancel": @{ + @"phasedRegistrationNames": @{ + @"bubbled": @"onTouchCancel", + @"captured": @"onTouchCancelCapture" + } + }, + @"topTouchEnd": @{ + @"phasedRegistrationNames": @{ + @"bubbled": @"onTouchEnd", + @"captured": @"onTouchEndCapture" + } + }, + } mutableCopy]; + + [RCTViewModuleClasses() enumerateKeysAndObjectsUsingBlock:^(NSString *name, Class cls, BOOL *stop) { + if (RCTClassOverridesClassMethod(cls, @selector(customBubblingEventTypes))) { + NSDictionary *eventTypes = [cls customBubblingEventTypes]; + for (NSString *eventName in eventTypes) { + RCTCAssert(!customBubblingEventTypesConfigs[eventName], @"Event '%@' registered multiple times.", eventName); + } + [customBubblingEventTypesConfigs addEntriesFromDictionary:eventTypes]; + } + }]; + + return customBubblingEventTypesConfigs; +} + ++ (NSDictionary *)allDirectEventTypesConfigs +{ + NSMutableDictionary *customDirectEventTypes = [@{ + @"topScrollBeginDrag": @{ + @"registrationName": @"onScrollBeginDrag" + }, + @"topScroll": @{ + @"registrationName": @"onScroll" + }, + @"topScrollEndDrag": @{ + @"registrationName": @"onScrollEndDrag" + }, + @"topScrollAnimationEnd": @{ + @"registrationName": @"onScrollAnimationEnd" + }, + @"topSelectionChange": @{ + @"registrationName": @"onSelectionChange" + }, + @"topMomentumScrollBegin": @{ + @"registrationName": @"onMomentumScrollBegin" + }, + @"topMomentumScrollEnd": @{ + @"registrationName": @"onMomentumScrollEnd" + }, + @"topPullToRefresh": @{ + @"registrationName": @"onPullToRefresh" + }, + @"topLoadingStart": @{ + @"registrationName": @"onLoadingStart" + }, + @"topLoadingFinish": @{ + @"registrationName": @"onLoadingFinish" + }, + @"topLoadingError": @{ + @"registrationName": @"onLoadingError" + }, + } mutableCopy]; + + [RCTViewModuleClasses() enumerateKeysAndObjectsUsingBlock:^(NSString *name, Class cls, BOOL *stop) { + if (RCTClassOverridesClassMethod(cls, @selector(customDirectEventTypes))) { + NSDictionary *eventTypes = [cls customDirectEventTypes]; + for (NSString *eventName in eventTypes) { + RCTCAssert(!customDirectEventTypes[eventName], @"Event '%@' registered multiple times.", eventName); + } + [customDirectEventTypes addEntriesFromDictionary:eventTypes]; + } + }]; + + return customDirectEventTypes; +} + ++ (NSDictionary *)constantsToExport +{ + NSMutableDictionary *allJSConstants = [@{ + @"customBubblingEventTypes": [self allBubblingEventTypesConfigs], + @"customDirectEventTypes": [self allDirectEventTypesConfigs], + @"NSTextAlignment": @{ + @"Left": @(NSTextAlignmentLeft), + @"Center": @(NSTextAlignmentCenter), + @"Right": @(NSTextAlignmentRight), + }, + @"Dimensions": @{ + @"window": @{ + @"width": @(RCTScreenSize().width), + @"height": @(RCTScreenSize().height), + @"scale": @(RCTScreenScale()), + }, + @"modalFullscreenView": @{ + @"width": @(RCTScreenSize().width), + @"height": @(RCTScreenSize().width), + }, + }, + @"StyleConstants": @{ + @"PointerEventsValues": @{ + @"none": @(RCTPointerEventsNone), + @"boxNone": @(RCTPointerEventsBoxNone), + @"boxOnly": @(RCTPointerEventsBoxOnly), + @"unspecified": @(RCTPointerEventsUnspecified), + }, + }, + @"UIText": @{ + @"AutocapitalizationType": @{ + @"AllCharacters": @(UITextAutocapitalizationTypeAllCharacters), + @"Sentences": @(UITextAutocapitalizationTypeSentences), + @"Words": @(UITextAutocapitalizationTypeWords), + @"None": @(UITextAutocapitalizationTypeNone), + }, + }, + @"UIView": @{ + @"ContentMode": @{ + @"ScaleToFill": @(UIViewContentModeScaleToFill), + @"ScaleAspectFit": @(UIViewContentModeScaleAspectFit), + @"ScaleAspectFill": @(UIViewContentModeScaleAspectFill), + @"Redraw": @(UIViewContentModeRedraw), + @"Center": @(UIViewContentModeCenter), + @"Top": @(UIViewContentModeTop), + @"Bottom": @(UIViewContentModeBottom), + @"Left": @(UIViewContentModeLeft), + @"Right": @(UIViewContentModeRight), + @"TopLeft": @(UIViewContentModeTopLeft), + @"TopRight": @(UIViewContentModeTopRight), + @"BottomLeft": @(UIViewContentModeBottomLeft), + @"BottomRight": @(UIViewContentModeBottomRight), + }, + }, + } mutableCopy]; + + [RCTViewModuleClasses() enumerateKeysAndObjectsUsingBlock:^(NSString *name, Class cls, BOOL *stop) { + // TODO: should these be inherited? + NSDictionary *constants = RCTClassOverridesClassMethod(cls, @selector(constantsToExport)) ? [cls constantsToExport] : nil; + if ([constants count]) { + NSMutableDictionary *namespace = [NSMutableDictionary dictionaryWithDictionary:allJSConstants[name]]; + RCTAssert(namespace[@"Constants"] == nil , @"Cannot redefine Constants in namespace: %@", name); + // add an additional 'Constants' namespace for each class + namespace[@"Constants"] = constants; + allJSConstants[name] = [namespace copy]; + } + }]; + + return allJSConstants; +} + +- (void)configureNextLayoutAnimation:(NSDictionary *)config + withCallback:(RCTResponseSenderBlock)callback + errorCallback:(RCTResponseSenderBlock)errorCallback +{ + RCT_EXPORT(); + + if (_nextLayoutAnimation) { + RCTLogWarn(@"Warning: Overriding previous layout animation with new one before the first began:\n%@ -> %@.", _nextLayoutAnimation, config); + } + if (config[@"delete"] != nil) { + RCTLogError(@"LayoutAnimation only supports create and update right now. Config: %@", config); + } + _nextLayoutAnimation = [[RCTLayoutAnimation alloc] initWithDictionary:config callback:callback]; +} + +static UIView *_jsResponder; + ++ (UIView *)JSResponder +{ + return _jsResponder; +} + +@end diff --git a/ReactKit/Views/RCTNavItem.h b/ReactKit/Views/RCTNavItem.h new file mode 100644 index 0000000000..68f12fd456 --- /dev/null +++ b/ReactKit/Views/RCTNavItem.h @@ -0,0 +1,14 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import + +@interface RCTNavItem : UIView + +@property (nonatomic, copy) NSString *title; +@property (nonatomic, copy) NSString *rightButtonTitle; +@property (nonatomic, copy) NSString *backButtonTitle; +@property (nonatomic, copy) UIColor *tintColor; +@property (nonatomic, copy) UIColor *barTintColor; +@property (nonatomic, copy) UIColor *titleTextColor; + +@end diff --git a/ReactKit/Views/RCTNavItem.m b/ReactKit/Views/RCTNavItem.m new file mode 100644 index 0000000000..191bfce104 --- /dev/null +++ b/ReactKit/Views/RCTNavItem.m @@ -0,0 +1,8 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTNavItem.h" + +@implementation RCTNavItem + +@end + diff --git a/ReactKit/Views/RCTNavItemManager.h b/ReactKit/Views/RCTNavItemManager.h new file mode 100644 index 0000000000..3c2a321057 --- /dev/null +++ b/ReactKit/Views/RCTNavItemManager.h @@ -0,0 +1,8 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTViewManager.h" + +@interface RCTNavItemManager : RCTViewManager + +@end + diff --git a/ReactKit/Views/RCTNavItemManager.m b/ReactKit/Views/RCTNavItemManager.m new file mode 100644 index 0000000000..515b3437bd --- /dev/null +++ b/ReactKit/Views/RCTNavItemManager.m @@ -0,0 +1,23 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTNavItemManager.h" + +#import "RCTConvert.h" +#import "RCTNavItem.h" + +@implementation RCTNavItemManager + +- (UIView *)view +{ + return [[RCTNavItem alloc] init]; +} + +RCT_EXPORT_VIEW_PROPERTY(title) +RCT_EXPORT_VIEW_PROPERTY(rightButtonTitle); +RCT_EXPORT_VIEW_PROPERTY(backButtonTitle); +RCT_EXPORT_VIEW_PROPERTY(tintColor); +RCT_EXPORT_VIEW_PROPERTY(barTintColor); +RCT_EXPORT_VIEW_PROPERTY(titleTextColor); + +@end + diff --git a/ReactKit/Views/RCTNavigator.h b/ReactKit/Views/RCTNavigator.h new file mode 100644 index 0000000000..5d928efa7b --- /dev/null +++ b/ReactKit/Views/RCTNavigator.h @@ -0,0 +1,26 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import + +#import "RCTInvalidating.h" + +@class RCTEventDispatcher; + +@interface RCTNavigator : UIView + +@property (nonatomic, strong) UIView *reactNavSuperviewLink; +@property (nonatomic, assign) NSInteger requestedTopOfStack; + +- (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher; + +/** + * Schedules a JavaScript navigation and prevents `UIKit` from navigating until + * JavaScript has sent its scheduled navigation. + * + * @returns Whether or not a JavaScript driven navigation could be + * scheduled/reserved. If returning `NO`, JavaScript should usually just do + * nothing at all. + */ +- (BOOL)requestSchedulingJavaScriptNavigation; + +@end diff --git a/ReactKit/Views/RCTNavigator.m b/ReactKit/Views/RCTNavigator.m new file mode 100644 index 0000000000..74e1621c25 --- /dev/null +++ b/ReactKit/Views/RCTNavigator.m @@ -0,0 +1,599 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTNavigator.h" + +#import "RCTAssert.h" +#import "RCTConvert.h" +#import "RCTEventDispatcher.h" +#import "RCTLog.h" +#import "RCTNavItem.h" +#import "RCTUtils.h" +#import "RCTView.h" +#import "RCTWrapperViewController.h" +#import "UIView+ReactKit.h" + +typedef NS_ENUM(NSUInteger, RCTNavigationLock) { + RCTNavigationLockNone, + RCTNavigationLockNative, + RCTNavigationLockJavaScript +}; + +NSInteger kNeverRequested = -1; +NSInteger kNeverProgressed = -10000; + + +@interface UINavigationController () + +// need to declare this since `UINavigationController` doesnt publicly declare the fact that it implements +// UINavigationBarDelegate :( +- (BOOL)navigationBar:(UINavigationBar *)navigationBar shouldPopItem:(UINavigationItem *)item; + +@end + +// http://stackoverflow.com/questions/5115135/uinavigationcontroller-how-to-cancel-the-back-button-event +// There's no other way to do this unfortunately :( +@interface RCTNavigationController : UINavigationController +{ + dispatch_block_t _scrollCallback; +} + +@property (nonatomic, assign) RCTNavigationLock navigationLock; + +@end + + +/** + * In general, `RCTNavigator` examines `_currentViews` (which are React child + * views), and compares them to `_navigationController.viewControllers` (which + * are controlled by UIKit). + * + * It is possible for JavaScript (`_currentViews`) to "get ahead" of native + * (`navigationController.viewControllers`) and vice versa. JavaScript gets + * ahead by adding/removing React subviews. Native gets ahead by swiping back, + * or tapping the back button. In both cases, the other system is initially + * unaware. And in both cases, `RCTNavigator` helps the other side "catch up". + * + * If `RCTNavigator` sees the number of react children have changed, it + * pushes/pops accordingly. If `RCTNavigator` sees a `UIKit` driven push/pop, it + * notifies JavaScript that this has happened, and expects that JavaScript will + * eventually render more children to match `UIKit`. There's no rush for + * JavaScript to catch up. But if it does rener anything, it must catch up to + * UIKit. It cannot deviate. + * + * To implement this, we need a lock, which we store on the native thread. This + * lock allows one of the systems to push/pop views. Whoever wishes to + * "get ahead" must obtain the lock. Whoever wishes to "catch up" must obtain + * the lock. One thread may not "get ahead" or "catch up" when the other has + * the lock. Once a thread has the lock, it can only do the following: + * + * 1. If it is behind, it may only catch up. + * 2. If it is caught up or ahead, it may push or pop. + * + * + * ========= Acquiring The Lock ========== + * + * JavaScript asynchronously acquires the lock using a native hook. It might be + * rejected and receive the return value `false`. + * + * We acquire the native lock in `shouldPopItem`, which is called right before + * native tries to push/pop, but only if JavaScript doesn't already have the + * lock. + * + * ======== While JavaScript Has Lock ==== + * + * When JavaScript has the lock, we have to block all `UIKit` driven pops: + * + * 1. Block back button navigation: + * - Back button will invoke `shouldPopItem`, from which we return `NO` if + * JavaScript has the lock. + * - Back button will respect the return value `NO` and not permit + * navigation. + * + * 2. Block swipe-to-go-back navigation: + * - Swipe will trigger `shouldPopItem`, but swipe won't respect our `NO` + * return value so we must disable the gesture recognizer while JavaScript + * has the lock. + * + * ======== While Native Has Lock ======= + * + * We simply deny JavaScript the right to acquire the lock. + * + * + * ======== Releasing The Lock =========== + * + * Recall that the lock represents who has the right to either push/pop (or + * catch up). As soon as we recognize that the side that has locked has carried + * out what it scheduled to do, we can release the lock, but only after any + * possible animations are completed. + * + * *IF* a scheduled operation results in a push/pop (not all do), then we can + * only release the lock after the push/pop animation is complete because + * UIKit. `didMoveToNavigationController` is invoked when the view is done + * pushing/popping/animating. Native swipe-to-go-back interactions can be + * aborted, however, and you'll never see that method invoked. So just to cover + * that case, we also put an animation complete hook in + * `animateAlongsideTransition` to make sure we free the lock, in case the + * scheduled native push/pop never actually happened. + * + * For JavaScript: + * - When we see that JavaScript has "caught up" to `UIKit`, and no pushes/pops + * were needed, we can release the lock. + * - When we see that JavaScript requires *some* push/pop, it's not yet done + * carrying out what it scheduled to do. Just like with `UIKit` push/pops, we + * still have to wait for it to be done animating + * (`didMoveToNavigationController` is a suitable hook). + * + */ +@implementation RCTNavigationController + +- (instancetype)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil +{ + RCT_NOT_DESIGNATED_INITIALIZER(); +} + +- (instancetype)initWithNavigationBarClass:(Class)navigationBarClass toolbarClass:(Class)toolbarClass +{ + RCT_NOT_DESIGNATED_INITIALIZER(); +} + +- (instancetype)initWithRootViewController:(UIViewController *)rootViewController +{ + RCT_NOT_DESIGNATED_INITIALIZER(); +} + +/** + * @param callback Callback that is invoked when a "scroll" interaction begins + * so that `RCTNavigator` can notify `JavaScript`. + */ +- (instancetype)initWithScrollCallback:(dispatch_block_t)callback +{ + if ((self = [super initWithNibName:nil bundle:nil])) { + _scrollCallback = callback; + } + return self; +} + + +/** + * Invoked when either a navigation item has been popped off, or when a + * swipe-back gesture has began. The swipe-back gesture doesn't respect the + * return value of this method. The back button does. That's why we have to + * completely disable the gesture recognizer for swipe-back while JS has the + * lock. + */ +- (BOOL)navigationBar:(UINavigationBar *)navigationBar shouldPopItem:(UINavigationItem *)item +{ + if (self.interactivePopGestureRecognizer.state == UIGestureRecognizerStateBegan) { + if (self.navigationLock == RCTNavigationLockNone) { + self.navigationLock = RCTNavigationLockNative; + if (_scrollCallback) { + _scrollCallback(); + } + } else if (self.navigationLock == RCTNavigationLockJavaScript) { + // This should never happen because we disable/enable the gesture + // recognizer when we lock the navigation. + RCTAssert(NO, @"Should never receive gesture start while JS locks navigator"); + } + } else { + if (self.navigationLock == RCTNavigationLockNone) { + // Must be coming from native interaction, lock it - it will be unlocked + // in `didMoveToNavigationController` + self.navigationLock = RCTNavigationLockNative; + if (_scrollCallback) { + _scrollCallback(); + } + } else if (self.navigationLock == RCTNavigationLockJavaScript) { + // This should only occur when JS has the lock, and + // - JS is driving the pop + // - Or the back button was pressed + // TODO: We actually want to disable the backbutton while JS has the + // lock, but it's not so easy. Even returning `NO` wont' work because it + // will also block JS driven pops. We simply need to disallow a standard + // back button, and instead use a custom one that tells JS to pop to + // length (`currentReactCount` - 1). + return [super navigationBar:navigationBar shouldPopItem:item]; + } + } + return [super navigationBar:navigationBar shouldPopItem:item]; +} + +@end + + +@interface RCTNavigator() +{ + RCTEventDispatcher *_eventDispatcher; + NSInteger _numberOfViewControllerMovesToIgnore; +} + +@property (nonatomic, assign) NSInteger previousRequestedTopOfStack; + +// Previous views are only mainted in order to detect incorrect +// addition/removal of views below the `requestedTopOfStack` +@property (nonatomic, copy, readwrite) NSArray *previousViews; +@property (nonatomic, readwrite, strong) NSMutableArray *currentViews; +@property (nonatomic, readwrite, strong) RCTNavigationController *navigationController; +/** + * Display link is used to get high frequency sample rate during + * interaction/animation of view controller push/pop. + * + * - The run loop retains the displayLink. + * - `displayLink` retains its target. + * - We use `reactWillDestroy` to remove the `RCTNavigator`'s reference to the + * `displayLink` and remove the `displayLink` from the run loop. + * + * + * `displayLink`: + * -------------- + * + * - Even though we could implement the `displayLink` cleanup without the + * `reactWillDestroy` hook by adding and removing it from the run loop at the + * right times (begin/end animation), we need to account for the possibility + * that the view itself is destroyed mid-interaction. So we always keep it + * added to the run loop, but start/stop it with interactions/animations. We + * remove it from the run loop when the view will be destroyed by React. + * + * +----------+ +--------------+ + * | run loop o----strong--->| displayLink | + * +----------+ +--o-----------+ + * | ^ + * | | + * strong strong + * | | + * v | + * +---------o---+ + * | RCTNavigator | + * +-------------+ + * + * `dummyView`: + * ------------ + * There's no easy way to get a callback that fires when the position of a + * navigation item changes. The actual layers that are moved around during the + * navigation transition are private. Our only hope is to use + * `animateAlongsideTransition`, to set a dummy view's position to transition + * anywhere from -1.0 to 1.0. We later set up a `CADisplayLink` to poll the + * `presentationLayer` of that dummy view and report the value as a "progress" + * percentage. + * + * It was critical that we added the dummy view as a subview of the + * transitionCoordinator's `containerView`, otherwise the animations would not + * work correctly when reversing the gesture direction etc. This seems to be + * undocumented behavior/requirement. + * + */ +@property (nonatomic, readonly, assign) CGFloat mostRecentProgress; +@property (nonatomic, readwrite, strong) CADisplayLink *displayLink; +@property (nonatomic, readonly, strong) NSTimer *runTimer; +@property (nonatomic, readonly, assign) NSInteger currentlyTransitioningFrom; +@property (nonatomic, readonly, assign) NSInteger currentlyTransitioningTo; + +// Dummy view that we make animate with the same curve/interaction as the +// navigation animation/interaction. +@property (nonatomic, readonly, strong) UIView *dummyView; + +@end + +@implementation RCTNavigator + +- (instancetype)initWithFrame:(CGRect)frame +{ + RCT_NOT_DESIGNATED_INITIALIZER(); +} + +- (id)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher +{ + if ((self = [super initWithFrame:CGRectZero])) { + _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(reportNavigationProgress:)]; + _mostRecentProgress = kNeverProgressed; + _dummyView = [[UIView alloc] initWithFrame:CGRectZero]; + if (_displayLink) { + [_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes]; + _displayLink.paused = YES; + } else { + // It's okay to leak this on a build bot. + RCTLogWarn(@"Failed to create a display link (probably on automated build system) - using an NSTimer for AppEngine instead."); + _runTimer = [NSTimer scheduledTimerWithTimeInterval:(1.0 / 60.0) target:self selector:@selector(reportNavigationProgress:) userInfo:nil repeats:YES]; + } + _eventDispatcher = eventDispatcher; + _previousRequestedTopOfStack = kNeverRequested; // So that we initialize with a push. + _previousViews = @[]; + _currentViews = [[NSMutableArray alloc] initWithCapacity:0]; + __weak RCTNavigator *weakSelf = self; + _navigationController = [[RCTNavigationController alloc] initWithScrollCallback:^{ + [weakSelf dispatchFakeScrollEvent]; + }]; + _navigationController.delegate = self; + RCTAssert([self requestSchedulingJavaScriptNavigation], @"Could not acquire JS navigation lock on init"); + + [self addSubview:_navigationController.view]; + [_navigationController.view addSubview:_dummyView]; + } + + return self; +} + +- (void)reportNavigationProgress:(CADisplayLink *)sender +{ + if (_currentlyTransitioningFrom != _currentlyTransitioningTo) { + UIView *topView = _dummyView; + id presentationLayer = [topView.layer presentationLayer]; + CGRect frame = [presentationLayer frame]; + CGFloat nextProgress = ABS(frame.origin.x); + // Don't want to spam the bridge, when the user holds their finger still mid-navigation. + if (nextProgress == _mostRecentProgress) { + return; + } + _mostRecentProgress = nextProgress; + [_eventDispatcher sendInputEventWithName:@"topNavigationProgress" body:@{ + @"fromIndex": @(_currentlyTransitioningFrom), + @"toIndex": @(_currentlyTransitioningTo), + @"progress": @(nextProgress), + @"target": self.reactTag + }]; + } +} + +- (void)dealloc +{ + _navigationController.delegate = nil; +} + +- (UIViewController *)backingViewController +{ + return _navigationController; +} + +/** + * See documentation about lock lifecycle. This is only here to clean up + * swipe-back abort interaction, which leaves us *no* other way to clean up + * locks aside from the animation complete hook. + */ +- (void)navigationController:(UINavigationController *)navigationController + willShowViewController:(UIViewController *)viewController + animated:(BOOL)animated +{ + id tc = + navigationController.topViewController.transitionCoordinator; + __weak RCTNavigator *weakSelf = self; + [tc.containerView addSubview: _dummyView]; + [tc animateAlongsideTransition: ^(id context) { + RCTWrapperViewController *fromController = + (RCTWrapperViewController *)[context viewControllerForKey:UITransitionContextFromViewControllerKey]; + RCTWrapperViewController *toController = + (RCTWrapperViewController *)[context viewControllerForKey:UITransitionContextToViewControllerKey]; + NSUInteger indexOfFrom = [_currentViews indexOfObject:fromController.navItem]; + NSUInteger indexOfTo = [_currentViews indexOfObject:toController.navItem]; + CGFloat destination = indexOfFrom < indexOfTo ? 1.0f : -1.0f; + _dummyView.frame = CGRectMake(destination, 0.0f, 0.0f, 0.0f); + _currentlyTransitioningFrom = indexOfFrom; + _currentlyTransitioningTo = indexOfTo; + if (indexOfFrom != indexOfTo) { + _displayLink.paused = NO; + } + } + completion:^(id context) { + [weakSelf freeLock]; + _currentlyTransitioningFrom = 0; + _currentlyTransitioningTo = 0; + _dummyView.frame = CGRectMake(0.0f, 0.0f, 0.0f, 0.0f); + _displayLink.paused = YES; + // Reset the parallel position tracker + }]; +} + +- (BOOL)requestSchedulingJavaScriptNavigation +{ + if (_navigationController.navigationLock == RCTNavigationLockNone) { + _navigationController.navigationLock = RCTNavigationLockJavaScript; + _navigationController.interactivePopGestureRecognizer.enabled = NO; + return YES; + } + return NO; +} + +- (void)freeLock +{ + _navigationController.navigationLock = RCTNavigationLockNone; + _navigationController.interactivePopGestureRecognizer.enabled = YES; +} + +/** + * A React subview can be inserted/removed at any time, however if the + * `requestedTopOfStack` changes, there had better be enough subviews present + * to satisfy the push/pop. + */ +- (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex +{ + RCTAssert([view isKindOfClass:[RCTNavItem class]], @"RCTNavigator only accepts RCTNavItem subviews"); + RCTAssert( + _navigationController.navigationLock == RCTNavigationLockJavaScript, + @"Cannot change subviews from JS without first locking." + ); + [_currentViews insertObject:view atIndex:atIndex]; +} + +- (NSArray *)reactSubviews +{ + return _currentViews; +} + +- (BOOL)isValid +{ + return _displayLink != nil; +} + +- (void)invalidate +{ + // Prevent displayLink from retaining the navigator indefinitely + [_displayLink invalidate]; + _displayLink = nil; + _runTimer = nil; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + _navigationController.view.frame = self.bounds; +} + +- (void)removeReactSubview:(UIView *)subview +{ + if (_currentViews.count <= 0 || subview == _currentViews[0]) { + RCTLogError(@"Attempting to remove invalid RCT subview of RCTNavigator"); + return; + } + [_currentViews removeObject:subview]; +} + +- (void)handleTopOfStackChanged +{ + [_eventDispatcher sendInputEventWithName:@"topNavigateBack" body:@{ + @"target":self.reactTag, + @"stackLength":@(_navigationController.viewControllers.count) + }]; +} + +- (void)dispatchFakeScrollEvent +{ + [_eventDispatcher sendScrollEventWithType:RCTScrollEventTypeMove + reactTag:self.reactTag + scrollView:nil + userData:nil]; +} + +/** + * Must be overridden because UIKit destroys the views superview link when used + * as a navigator - it's considered outside the view hierarchy. + */ +- (UIView *)reactSuperview +{ + RCTAssert(self.superview != nil, @"put reactNavSuperviewLink back"); + return self.superview ? self.superview : self.reactNavSuperviewLink; +} + +- (void)addControllerToClosestParent:(UIViewController *)controller +{ + if (!controller.parentViewController) { + id responder = [self.superview nextResponder]; + while (responder && ![responder isKindOfClass:[UIViewController class]]) { + responder = [responder nextResponder]; + } + if (responder) { + [responder addChildViewController:controller]; + [controller didMoveToParentViewController:responder]; + } + } +} + +- (void)reactBridgeDidFinishTransaction +{ + // we can't hook up the VC hierarchy in 'init' because the subviews aren't hooked up yet, + // so we do it on demand here + [self addControllerToClosestParent:_navigationController]; + + NSInteger viewControllerCount = _navigationController.viewControllers.count; + // The "react count" is the count of views that are visible on the navigation + // stack. There may be more beyond this - that aren't visible, and may be + // deleted/purged soon. + NSInteger previousReactCount = + _previousRequestedTopOfStack == kNeverRequested ? 0 : _previousRequestedTopOfStack + 1; + NSInteger currentReactCount = _requestedTopOfStack + 1; + + BOOL jsGettingAhead = + // ----- previously caught up ------ ------ no longer caught up ------- + viewControllerCount == previousReactCount && currentReactCount != viewControllerCount; + BOOL jsCatchingUp = + // --- previously not caught up ---- --------- now caught up ---------- + viewControllerCount != previousReactCount && currentReactCount == viewControllerCount; + BOOL jsMakingNoProgressButNeedsToCatchUp = + // --- previously not caught up ---- ------- still the same ----------- + viewControllerCount != previousReactCount && currentReactCount == previousReactCount; + BOOL jsMakingNoProgressAndDoesntNeedTo = + // --- previously caught up -------- ------- still caught up ---------- + viewControllerCount == previousReactCount && currentReactCount == previousReactCount; + + BOOL reactPushOne = jsGettingAhead && currentReactCount == previousReactCount + 1; + BOOL reactPopN = jsGettingAhead && currentReactCount < previousReactCount; + + // We can actually recover from this situation, but it would be nice to know + // when this error happens. This simply means that JS hasn't caught up to a + // back navigation before progressing. It's likely a bug in the JS code that + // catches up/schedules navigations. Eventually, let's recover from this + // error state, but in the mean time, let's get notified about any JS bugs. + RCTAssert( + jsGettingAhead || + jsCatchingUp || + jsMakingNoProgressButNeedsToCatchUp || + jsMakingNoProgressAndDoesntNeedTo, + @"JS has only made partial progress to catch up to UIKit" + ); + NSAssert( + currentReactCount <= _currentViews.count, + @"Cannot adjust current top of stack beyond available views" + ); + + // Views before the previous react count must not have changed. Views greater than previousReactCount + // up to currentReactCount may have changed. + for (NSInteger i = 0; i < MIN(_currentViews.count, MIN(_previousViews.count, previousReactCount)); i++) { + NSAssert(_currentViews[i] == _previousViews[i], @"current view should equal previous view"); + } + RCTAssert(currentReactCount >= 1, @"should be at least one current view"); + if (jsGettingAhead) { + if (reactPushOne) { + UIView *lastView = [_currentViews lastObject]; + RCTWrapperViewController *vc = [[RCTWrapperViewController alloc] initWithNavItem:(RCTNavItem *)lastView eventDispatcher:_eventDispatcher]; + vc.navigationListener = self; + _numberOfViewControllerMovesToIgnore = 1; + [_navigationController pushViewController:vc animated:(currentReactCount > 1)]; + } else if (reactPopN) { + UIViewController *viewControllerToPopTo = [[_navigationController viewControllers] objectAtIndex:(currentReactCount - 1)]; + _numberOfViewControllerMovesToIgnore = viewControllerCount - currentReactCount; + [_navigationController popToViewController:viewControllerToPopTo animated:YES]; + } else { + RCTAssert(NO, @"Pushing or popping more than one view at a time from JS"); + } + } else if (jsCatchingUp) { + [self freeLock]; // Nothing to push/pop + } else { + // Else, JS making no progress, could have been unrelated to anything nav. + return; + } + + _previousViews = [_currentViews copy]; + _previousRequestedTopOfStack = _requestedTopOfStack; +} + + +// TODO: This will likely fail when performing multiple pushes/pops. We must +// free the lock only after the *last* push/pop. +- (void)wrapperViewController:(RCTWrapperViewController *)wrapperViewController +didMoveToNavigationController:(UINavigationController *)navigationController +{ + if (self.superview == nil) { + // If superview is nil, then a JS reload (Cmd+R) happened + // while a push/pop is in progress. + return; + } + + RCTAssert( + (navigationController == nil || [_navigationController.viewControllers containsObject:wrapperViewController]), + @"if navigation controller is not nil, it should container the wrapper view controller" + ); + RCTAssert(_navigationController.navigationLock == RCTNavigationLockJavaScript || + _numberOfViewControllerMovesToIgnore == 0, + @"If JS doesn't have the lock there should never be any pending transitions"); + /** + * When JS has the lock we want to keep track of when the request completes + * the pending transition count hitting 0 signifies this, and should always + * remain at 0 when JS does not have the lock + */ + if (_numberOfViewControllerMovesToIgnore > 0) { + _numberOfViewControllerMovesToIgnore -= 1; + } + if (_numberOfViewControllerMovesToIgnore == 0) { + [self handleTopOfStackChanged]; + [self freeLock]; + } +} + +@end diff --git a/ReactKit/Views/RCTNavigatorManager.h b/ReactKit/Views/RCTNavigatorManager.h new file mode 100644 index 0000000000..d32d21096e --- /dev/null +++ b/ReactKit/Views/RCTNavigatorManager.h @@ -0,0 +1,8 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTViewManager.h" + +@interface RCTNavigatorManager : RCTViewManager + +@end + diff --git a/ReactKit/Views/RCTNavigatorManager.m b/ReactKit/Views/RCTNavigatorManager.m new file mode 100644 index 0000000000..973d4958d3 --- /dev/null +++ b/ReactKit/Views/RCTNavigatorManager.m @@ -0,0 +1,28 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTNavigatorManager.h" + +#import "RCTConvert.h" +#import "RCTNavigator.h" +#import "RCTShadowView.h" + +@implementation RCTNavigatorManager + +- (UIView *)view +{ + return [[RCTNavigator alloc] initWithEventDispatcher:self.eventDispatcher]; +} + +RCT_EXPORT_VIEW_PROPERTY(requestedTopOfStack) + +- (NSDictionary *)customDirectEventTypes +{ + return @{ + @"topNavigationProgress": @{ + @"registrationName": @"onNavigationProgress" + }, + }; +} + +@end + diff --git a/ReactKit/Views/RCTNetworkImageView.h b/ReactKit/Views/RCTNetworkImageView.h new file mode 100644 index 0000000000..c99ed0689a --- /dev/null +++ b/ReactKit/Views/RCTNetworkImageView.h @@ -0,0 +1,32 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import + +@class RCTImageDownloader; + +@interface RCTNetworkImageView : UIView + +- (instancetype)initWithFrame:(CGRect)frame imageDownloader:(RCTImageDownloader *)imageDownloader; + +/** + * An image that will appear while the view is loading the image from the network, + * or when imageURL is nil. Defaults to nil. + */ +@property (nonatomic, strong) UIImage *defaultImage; + +/** + * Specify a URL for an image. The image will be asynchronously loaded and displayed. + */ +@property (nonatomic, strong) NSURL *imageURL; + +/** + * By default, changing imageURL will reset whatever existing image was present + * and revert to defaultImage while the new image loads. In certain obscure cases you + * may want to disable this behavior and instead keep displaying the previous image + * while the new one loads. In this case, pass NO for resetToDefaultImageWhileLoading. + * (If you set imageURL to nil, however, resetToDefaultImageWhileLoading is ignored; + * that will always reset to the default image.) + */ +- (void)setImageURL:(NSURL *)imageURL resetToDefaultImageWhileLoading:(BOOL)reset; + +@end diff --git a/ReactKit/Views/RCTNetworkImageView.m b/ReactKit/Views/RCTNetworkImageView.m new file mode 100644 index 0000000000..2a739cea75 --- /dev/null +++ b/ReactKit/Views/RCTNetworkImageView.m @@ -0,0 +1,118 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTNetworkImageView.h" + +#import "RCTImageDownloader.h" +#import "RCTUtils.h" +#import "RCTConvert.h" + +@implementation RCTNetworkImageView +{ + BOOL _deferred; + NSURL *_imageURL; + NSURL *_deferredImageURL; + NSUInteger _deferSentinel; + RCTImageDownloader *_imageDownloader; + id _downloadToken; +} + +- (instancetype)initWithFrame:(CGRect)frame imageDownloader:(RCTImageDownloader *)imageDownloader +{ + self = [super initWithFrame:frame]; + if (self) { + _deferSentinel = 0; + _imageDownloader = imageDownloader; + self.userInteractionEnabled = NO; + } + return self; +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + RCT_NOT_DESIGNATED_INITIALIZER(); +} + +- (NSURL *)imageURL +{ + // We clear our backing layer's imageURL when we are not in a window for a while, + // to make sure we don't consume network resources while offscreen. + // However we don't want to expose this hackery externally. + return _deferred ? _deferredImageURL : _imageURL; +} + +- (void)setImageURL:(NSURL *)imageURL resetToDefaultImageWhileLoading:(BOOL)reset +{ + if (_deferred) { + _deferredImageURL = imageURL; + } else { + if (_downloadToken) { + [_imageDownloader cancelDownload:_downloadToken]; + _downloadToken = nil; + } + if (reset) { + self.layer.contentsScale = _defaultImage.scale; + self.layer.contents = (__bridge id)_defaultImage.CGImage; + } + if ([imageURL.pathExtension caseInsensitiveCompare:@"gif"] == NSOrderedSame) { + _downloadToken = [_imageDownloader downloadDataForURL:imageURL block:^(NSData *data, NSError *error) { + if (data) { + CAKeyframeAnimation *animation = [RCTConvert GIF:data]; + CGImageRef firstFrame = (__bridge CGImageRef)animation.values.firstObject; + self.layer.bounds = CGRectMake(0, 0, CGImageGetWidth(firstFrame), CGImageGetHeight(firstFrame)); + self.layer.contentsScale = 1.0; + self.layer.contentsGravity = kCAGravityResizeAspect; + [self.layer addAnimation:animation forKey:@"contents"]; + } + // TODO: handle errors + }]; + } else { + _downloadToken = [_imageDownloader downloadImageForURL:imageURL size:self.bounds.size scale:RCTScreenScale() block:^(UIImage *image, NSError *error) { + if (image) { + self.layer.contentsScale = image.scale; + self.layer.contents = (__bridge id)image.CGImage; + } + // TODO: handle errors + }]; + } + } +} + +- (void)setImageURL:(NSURL *)imageURL +{ + [self setImageURL:imageURL resetToDefaultImageWhileLoading:YES]; +} + +- (void)willMoveToWindow:(UIWindow *)newWindow +{ + [super willMoveToWindow:newWindow]; + if (newWindow != nil && _deferredImageURL) { + // Immediately exit deferred mode and restore the imageURL that we saved when we went offscreen. + [self setImageURL:_deferredImageURL resetToDefaultImageWhileLoading:YES]; + _deferredImageURL = nil; + } +} + +- (void)_enterDeferredModeIfNeededForSentinel:(NSUInteger)sentinel +{ + if (self.window == nil && _deferSentinel == sentinel) { + _deferred = YES; + [_imageDownloader cancelDownload:_downloadToken]; + _downloadToken = nil; + _deferredImageURL = _imageURL; + _imageURL = nil; + } +} + +- (void)didMoveToWindow +{ + [super didMoveToWindow]; + if (self.window == nil) { + __weak RCTNetworkImageView *weakSelf = self; + NSUInteger sentinelAtDispatchTime = ++_deferSentinel; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC), dispatch_get_main_queue(), ^(void){ + [weakSelf _enterDeferredModeIfNeededForSentinel:sentinelAtDispatchTime]; + }); + } +} + +@end diff --git a/ReactKit/Views/RCTNetworkImageViewManager.h b/ReactKit/Views/RCTNetworkImageViewManager.h new file mode 100644 index 0000000000..5b34e60602 --- /dev/null +++ b/ReactKit/Views/RCTNetworkImageViewManager.h @@ -0,0 +1,8 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTViewManager.h" + +@interface RCTNetworkImageViewManager : RCTViewManager + +@end + diff --git a/ReactKit/Views/RCTNetworkImageViewManager.m b/ReactKit/Views/RCTNetworkImageViewManager.m new file mode 100644 index 0000000000..5f8ad5d353 --- /dev/null +++ b/ReactKit/Views/RCTNetworkImageViewManager.m @@ -0,0 +1,26 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTNetworkImageViewManager.h" + +#import "RCTNetworkImageView.h" + +#import "RCTConvert.h" +#import "RCTUtils.h" + +#import "RCTImageDownloader.h" + +@implementation RCTNetworkImageViewManager + +- (UIView *)view +{ + RCTNetworkImageView *view = [[RCTNetworkImageView alloc] initWithFrame:CGRectZero imageDownloader:[RCTImageDownloader sharedInstance]]; + view.contentMode = UIViewContentModeScaleAspectFill; + return view; +} + +RCT_REMAP_VIEW_PROPERTY(defaultImageSrc, defaultImage) +RCT_REMAP_VIEW_PROPERTY(src, imageURL) +RCT_REMAP_VIEW_PROPERTY(resizeMode, contentMode) + +@end + diff --git a/ReactKit/Views/RCTRawTextManager.h b/ReactKit/Views/RCTRawTextManager.h new file mode 100644 index 0000000000..4c67fd3285 --- /dev/null +++ b/ReactKit/Views/RCTRawTextManager.h @@ -0,0 +1,7 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTViewManager.h" + +@interface RCTRawTextManager : RCTViewManager + +@end diff --git a/ReactKit/Views/RCTRawTextManager.m b/ReactKit/Views/RCTRawTextManager.m new file mode 100644 index 0000000000..57ee752041 --- /dev/null +++ b/ReactKit/Views/RCTRawTextManager.m @@ -0,0 +1,20 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTRawTextManager.h" + +#import "RCTShadowRawText.h" + +@implementation RCTRawTextManager + +- (UIView *)view +{ + return [[UIView alloc] init]; +} + +- (RCTShadowView *)shadowView +{ + return [[RCTShadowRawText alloc] init]; +} + +@end + diff --git a/ReactKit/Views/RCTScrollView.h b/ReactKit/Views/RCTScrollView.h new file mode 100644 index 0000000000..82667b2051 --- /dev/null +++ b/ReactKit/Views/RCTScrollView.h @@ -0,0 +1,37 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import + +#import "RCTAutoInsetsProtocol.h" +#import "RCTScrollableProtocol.h" +#import "RCTView.h" + +@protocol UIScrollViewDelegate; + +@class RCTEventDispatcher; + +@interface RCTScrollView : RCTView + +/** + * If the `contentSize` is not provided, then the `contentSize` will + * automatically be determined by the size of the `RKScrollView` subview. + * + * The `RCTScrollView` may have at most one single subview. This will ensure + * that the scroll view's `contentSize` will be efficiently set to the size of + * the single subview's frame. That frame size will be determined somewhat + * efficiently since it will have already been computed by the off-main-thread + * layout system. + */ +@property (nonatomic, readonly) UIScrollView *scrollView; +@property (nonatomic, readonly) UIView *contentView; + +@property (nonatomic, assign) CGSize contentSize; +@property (nonatomic, assign) UIEdgeInsets contentInset; +@property (nonatomic, assign) BOOL automaticallyAdjustContentInsets; +@property (nonatomic, assign) NSUInteger throttleScrollCallbackMS; +@property (nonatomic, assign) BOOL centerContent; +@property (nonatomic, copy) NSArray *stickyHeaderIndices; + +- (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher; + +@end diff --git a/ReactKit/Views/RCTScrollView.m b/ReactKit/Views/RCTScrollView.m new file mode 100644 index 0000000000..d017c7edcf --- /dev/null +++ b/ReactKit/Views/RCTScrollView.m @@ -0,0 +1,598 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTScrollView.h" + +#import + +#import "RCTConvert.h" +#import "RCTEventDispatcher.h" +#import "RCTLog.h" +#import "RCTUIManager.h" +#import "RCTUtils.h" +#import "UIView+ReactKit.h" + +CGFloat const ZINDEX_DEFAULT = 0; +CGFloat const ZINDEX_STICKY_HEADER = 50; + +/** + * Include a custom scroll view subclass because we want to limit certain + * default UIKit behaviors such as textFields automatically scrolling + * scroll views that contain them and support sticky headers. + */ +@interface RCTCustomScrollView : UIScrollView + +@property (nonatomic, copy, readwrite) NSArray *stickyHeaderIndices; +@property (nonatomic, readwrite, assign) BOOL centerContent; + +@end + + +@implementation RCTCustomScrollView + +- (instancetype)initWithFrame:(CGRect)frame +{ + self = [super initWithFrame:frame]; + if (self) { + [self.panGestureRecognizer addTarget:self action:@selector(handleCustomPan:)]; + } + return self; +} + +- (UIView *)contentView +{ + return ((RCTScrollView *)self.superview).contentView; +} + +/** + * @return Whether or not the scroll view interaction should be blocked because + * JS was found to be the responder. + */ +- (BOOL)_shouldDisableScrollInteraction +{ + // Since this may be called on every pan, we need to make sure to only climb + // the hierarchy on rare occasions. + UIView *JSResponder = [RCTUIManager JSResponder]; + if (JSResponder && JSResponder != self.superview) { + BOOL superviewHasResponder = [self isDescendantOfView:JSResponder]; + return superviewHasResponder; + } + return NO; +} + +- (void)handleCustomPan:(UIPanGestureRecognizer *)sender +{ + if ([self _shouldDisableScrollInteraction]) { + self.panGestureRecognizer.enabled = NO; + self.panGestureRecognizer.enabled = YES; + // TODO: If mid bounce, animate the scroll view to a non-bounced position + // while disabling (but only if `stopScrollInteractionIfJSHasResponder` was + // called *during* a `pan`. Currently, it will just snap into place which + // is not so bad either. + // Another approach: + // self.scrollEnabled = NO; + // self.scrollEnabled = YES; + } +} + +- (void)scrollRectToVisible:(CGRect)rect animated:(BOOL)animated +{ + // noop +} + +/** + * Returning `YES` cancels touches for the "inner" `view` and causes a scroll. + * Returning `NO` causes touches to be directed to that inner view and prevents + * the scroll view from scrolling. + * + * `YES` -> Allows scrolling. + * `NO` -> Doesn't allow scrolling. + * + * By default this returns NO for all views that are UIControls and YES for + * everything else. What that does is allows scroll views to scroll even when a + * touch started inside of a `UIControl` (`UIButton` etc). For React scroll + * views, we want the default to be the same behavior as `UIControl`s so we + * return `YES` by default. But there's one case where we want to block the + * scrolling no matter what: When JS believes it has its own responder lock on + * a view that is *above* the scroll view in the hierarchy. So we abuse this + * `touchesShouldCancelInContentView` API in order to stop the scroll view from + * scrolling in this case. + * + * We are not aware of *any* other solution to the problem because alternative + * approaches require that we disable the scrollview *before* touches begin or + * move. This approach (`touchesShouldCancelInContentView`) works even if the + * JS responder is set after touches start/move because + * `touchesShouldCancelInContentView` is called as soon as the scroll view has + * been touched and dragged *just* far enough to decide to begin the "drag" + * movement of the scroll interaction. Returning `NO`, will cause the drag + * operation to fail. + * + * `touchesShouldCancelInContentView` will stop the *initialization* of a + * scroll pan gesture and most of the time this is sufficient. On rare + * occasion, the scroll gesture would have already initialized right before JS + * notifies native of the JS responder being set. In order to recover from that + * timing issue we have a fallback that kills any ongoing pan gesture that + * occurs when native is notified of a JS responder. + * + * Note: Explicitly returning `YES`, instead of relying on the default fixes + * (at least) one bug where if you have a UIControl inside a UIScrollView and + * tap on the UIControl and then start dragging (to scroll), it won't scroll. + * Chat with andras for more details. + * + * In order to have this called, you must have delaysContentTouches set to NO + * (which is the not the `UIKit` default). + */ +- (BOOL)touchesShouldCancelInContentView:(UIView *)view +{ + //TODO: shouldn't this call super if _shouldDisableScrollInteraction returns NO? + return ![self _shouldDisableScrollInteraction]; +} + +/* + * Automatically centers the content such that if the content is smaller than the + * ScrollView, we force it to be centered, but when you zoom or the content otherwise + * becomes larger than the ScrollView, there is no padding around the content but it + * can still fill the whole view. + */ +- (void)setContentOffset:(CGPoint)contentOffset +{ + UIView *contentView = [self contentView]; + if (contentView && _centerContent) { + CGSize subviewSize = contentView.frame.size; + CGSize scrollViewSize = self.bounds.size; + if (subviewSize.width < scrollViewSize.width) { + contentOffset.x = -(scrollViewSize.width - subviewSize.width) / 2.0; + } + if (subviewSize.height < scrollViewSize.height) { + contentOffset.y = -(scrollViewSize.height - subviewSize.height) / 2.0; + } + } + [super setContentOffset:contentOffset]; +} + +- (void)setBounds:(CGRect)bounds +{ + [super setBounds:bounds]; + [self dockClosestSectionHeader]; +} + +- (void)dockClosestSectionHeader +{ + UIView *contentView = [self contentView]; + if (_stickyHeaderIndices.count == 0 || !contentView) { + return; + } + + // find the section header that needs to be docked + NSInteger firstIndexInView = [[_stickyHeaderIndices firstObject] integerValue] + 1; + CGRect scrollBounds = self.bounds; + scrollBounds.origin.x += self.contentInset.left; + scrollBounds.origin.y += self.contentInset.top; + + NSInteger i = 0; + for (UIView *subview in contentView.subviews) { + CGRect rowFrame = [RCTCustomScrollView _calculateUntransformedFrame:subview]; + if (CGRectIntersectsRect(scrollBounds, rowFrame)) { + firstIndexInView = i; + break; + } + i++; + } + NSInteger stickyHeaderii = 0; + for (NSNumber *stickyHeaderI in _stickyHeaderIndices) { + if ([stickyHeaderI integerValue] > firstIndexInView) { + break; + } + stickyHeaderii++; + } + stickyHeaderii = MAX(0, stickyHeaderii - 1); + + // Set up transforms for the various section headers + NSInteger currentlyDockedIndex = [_stickyHeaderIndices[stickyHeaderii] integerValue]; + NSInteger previouslyDockedIndex = stickyHeaderii > 0 ? [_stickyHeaderIndices[stickyHeaderii-1] integerValue] : -1; + NSInteger nextDockedIndex = (stickyHeaderii < _stickyHeaderIndices.count - 1) ? + [_stickyHeaderIndices[stickyHeaderii + 1] integerValue] : -1; + + UIView *currentHeader = contentView.subviews[currentlyDockedIndex]; + UIView *previousHeader = previouslyDockedIndex >= 0 ? contentView.subviews[previouslyDockedIndex] : nil; + CGRect curFrame = [RCTCustomScrollView _calculateUntransformedFrame:currentHeader]; + + if (previousHeader) { + // the previous header is offset to sit right above the currentlyDockedHeader's initial position + // (so it scrolls away nicely once the currentHeader locks into position) + CGRect previousFrame = [RCTCustomScrollView _calculateUntransformedFrame:previousHeader]; + CGFloat yOffset = curFrame.origin.y - previousFrame.origin.y - previousFrame.size.height; + previousHeader.transform = CGAffineTransformMakeTranslation(0, yOffset); + } + + UIView *nextHeader = nextDockedIndex >= 0 ? contentView.subviews[nextDockedIndex] : nil; + CGRect nextFrame = [RCTCustomScrollView _calculateUntransformedFrame:nextHeader]; + + if (curFrame.origin.y < scrollBounds.origin.y) { + // scrolled off (or being scrolled off) the top of the screen + CGFloat yOffset = 0; + if (nextHeader && nextFrame.origin.y < scrollBounds.origin.y + curFrame.size.height) { + // next frame is bumping me off if scrolling down (or i'm bumping the next one off if scrolling up) + yOffset = nextFrame.origin.y - curFrame.origin.y - curFrame.size.height; + } else { + // standard sticky header position + yOffset = scrollBounds.origin.y - curFrame.origin.y; + } + currentHeader.transform = CGAffineTransformMakeTranslation(0, yOffset); + currentHeader.layer.zPosition = ZINDEX_STICKY_HEADER; + } else { + // i'm the current header but in the viewport, so just scroll in normal position + currentHeader.transform = CGAffineTransformIdentity; + currentHeader.layer.zPosition = ZINDEX_DEFAULT; + } + + // in our setup, 'next header' will always just scroll with the page + if (nextHeader) { + nextHeader.transform = CGAffineTransformIdentity; + nextHeader.layer.zPosition = ZINDEX_DEFAULT; + } +} + ++ (CGRect)_calculateUntransformedFrame:(UIView *)view +{ + CGRect frame = CGRectNull; + if (view) { + frame.size = view.bounds.size; + frame.origin = CGPointMake(view.layer.position.x - view.bounds.size.width * view.layer.anchorPoint.x, view.layer.position.y - view.bounds.size.height * view.layer.anchorPoint.y); + } + return frame; +} + +@end + +@implementation RCTScrollView +{ + RCTEventDispatcher *_eventDispatcher; + BOOL _contentSizeManuallySet; + RCTCustomScrollView *_scrollView; + UIView *_contentView; + NSTimeInterval _lastScrollDispatchTime; + NSMutableArray *_cachedChildFrames; + BOOL _allowNextScrollNoMatterWhat; +} + +@synthesize nativeMainScrollDelegate = _nativeMainScrollDelegate; + +- (instancetype)initWithFrame:(CGRect)frame +{ + RCT_NOT_DESIGNATED_INITIALIZER(); +} + +- (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher +{ + if ((self = [super initWithFrame:CGRectZero])) { + + _eventDispatcher = eventDispatcher; + _scrollView = [[RCTCustomScrollView alloc] initWithFrame:CGRectZero]; + _scrollView.delegate = self; + _scrollView.delaysContentTouches = NO; + _automaticallyAdjustContentInsets = YES; + _contentInset = UIEdgeInsetsZero; + + _throttleScrollCallbackMS = 0; + _lastScrollDispatchTime = CACurrentMediaTime(); + _cachedChildFrames = [[NSMutableArray alloc] init]; + + [self addSubview:_scrollView]; + } + return self; +} + +- (void)setRemoveClippedSubviews:(__unused BOOL)removeClippedSubviews +{ + // Does nothing +} + +- (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex +{ + RCTAssert(_contentView == nil, @"RCTScrollView may only contain a single subview"); + _contentView = view; + [_scrollView addSubview:view]; +} + +- (void)removeReactSubview:(UIView *)subview +{ + RCTAssert(_contentView == subview, @"Attempted to remove non-existent subview"); + _contentView = nil; + [subview removeFromSuperview]; +} + +- (NSArray *)reactSubviews +{ + return _contentView ? @[_contentView] : @[]; +} + +- (void)setCenterContent:(BOOL)centerContent +{ + _scrollView.centerContent = centerContent; +} + +- (void)setStickyHeaderIndices:(NSArray *)headerIndices +{ + RCTAssert(_scrollView.contentSize.width <= self.frame.size.width, + @"sticky headers are not supported with horizontal scrolled views"); + _scrollView.stickyHeaderIndices = headerIndices; +} + +/** + * Once you set the `contentSize`, it's assumed to be managed by you forever + * and we'll never automatically compute the size for you. + */ +- (void)setContentSize:(CGSize)contentSize +{ + _contentSize = contentSize; + _contentSizeManuallySet = YES; +} + +- (void)dealloc +{ + _scrollView.delegate = nil; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + RCTAssert(self.subviews.count == 1, @"we should only have exactly one subview"); + RCTAssert([self.subviews lastObject] == _scrollView, @"our only subview should be a scrollview"); + _scrollView.frame = self.bounds; + + [RCTView autoAdjustInsetsForView:self + withScrollView:_scrollView + updateOffset:YES]; +} + +- (void)setContentInset:(UIEdgeInsets)contentInset +{ + _contentInset = contentInset; + [RCTView autoAdjustInsetsForView:self + withScrollView:_scrollView + updateOffset:NO]; +} + +- (void)scrollToOffset:(CGPoint)offset +{ + [self scrollToOffset:offset animated:YES]; +} + +- (void)scrollToOffset:(CGPoint)offset animated:(BOOL)animated +{ + if (!CGPointEqualToPoint(_scrollView.contentOffset, offset)) { + [_scrollView setContentOffset:offset animated:animated]; + } +} + +- (void)zoomToRect:(CGRect)rect animated:(BOOL)animated +{ + [_scrollView zoomToRect:rect animated:animated]; +} + +#pragma mark - ScrollView delegate + +#define RCT_SCROLL_EVENT_HANDLER(delegateMethod, eventName) \ +- (void)delegateMethod:(UIScrollView *)scrollView \ +{ \ + [_eventDispatcher sendScrollEventWithType:eventName reactTag:self.reactTag scrollView:scrollView userData:nil]; \ + if ([_nativeMainScrollDelegate respondsToSelector:_cmd]) { \ + [_nativeMainScrollDelegate delegateMethod:scrollView]; \ + } \ +} + +#define RCT_FORWARD_SCROLL_EVENT(call) \ +if ([_nativeMainScrollDelegate respondsToSelector:_cmd]) { \ + [_nativeMainScrollDelegate call]; \ +} + +RCT_SCROLL_EVENT_HANDLER(scrollViewDidEndScrollingAnimation, RCTScrollEventTypeEndDeceleration) +RCT_SCROLL_EVENT_HANDLER(scrollViewWillBeginDecelerating, RCTScrollEventTypeStartDeceleration) +RCT_SCROLL_EVENT_HANDLER(scrollViewDidEndDecelerating, RCTScrollEventTypeEndDeceleration) +RCT_SCROLL_EVENT_HANDLER(scrollViewDidZoom, RCTScrollEventTypeMove) + +- (void)scrollViewDidScroll:(UIScrollView *)scrollView +{ + NSTimeInterval now = CACurrentMediaTime(); + NSTimeInterval throttleScrollCallbackSeconds = _throttleScrollCallbackMS / 1000.0; + + /** + * TODO: this logic looks wrong, and it may be because it is. Currently, if _throttleScrollCallbackMS + * is set to zero (the default), the "didScroll" event is only sent once per scroll, instead of repeatedly + * while scrolling as expected. However, if you "fix" that bug, ScrollView will generate repeated + * warnings, and behave strangely (ListView works fine however), so don't fix it unless you fix that too! + */ + if (_allowNextScrollNoMatterWhat || + (_throttleScrollCallbackMS != 0 && throttleScrollCallbackSeconds < (now - _lastScrollDispatchTime))) { + + // Calculate changed frames + NSMutableArray *updatedChildFrames = [[NSMutableArray alloc] init]; + [[_contentView reactSubviews] enumerateObjectsUsingBlock:^(UIView *subview, NSUInteger idx, BOOL *stop) { + + // Check if new or changed + CGRect newFrame = subview.frame; + BOOL frameChanged = NO; + if (_cachedChildFrames.count <= idx) { + frameChanged = YES; + [_cachedChildFrames addObject:[NSValue valueWithCGRect:newFrame]]; + } else if (!CGRectEqualToRect(newFrame, [_cachedChildFrames[idx] CGRectValue])) { + frameChanged = YES; + _cachedChildFrames[idx] = [NSValue valueWithCGRect:newFrame]; + } + + // Create JS frame object + if (frameChanged) { + [updatedChildFrames addObject: @{ + @"index": @(idx), + @"x": @(newFrame.origin.x), + @"y": @(newFrame.origin.y), + @"width": @(newFrame.size.width), + @"height": @(newFrame.size.height), + }]; + } + + }]; + + // If there are new frames, add them to event data + NSDictionary *userData = nil; + if (updatedChildFrames.count > 0) { + userData = @{@"updatedChildFrames": updatedChildFrames}; + } + + // Dispatch event + [_eventDispatcher sendScrollEventWithType:RCTScrollEventTypeMove + reactTag:self.reactTag + scrollView:scrollView + userData:userData]; + // Update dispatch time + _lastScrollDispatchTime = now; + _allowNextScrollNoMatterWhat = NO; + } + RCT_FORWARD_SCROLL_EVENT(scrollViewDidScroll:scrollView); +} + +- (void)scrollViewWillBeginDragging:(UIScrollView *)scrollView +{ + _allowNextScrollNoMatterWhat = YES; // Ensure next scroll event is recorded, regardless of throttle + [_eventDispatcher sendScrollEventWithType:RCTScrollEventTypeStart reactTag:self.reactTag scrollView:scrollView userData:nil]; + RCT_FORWARD_SCROLL_EVENT(scrollViewWillBeginDragging:scrollView); +} + +- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset +{ + [_eventDispatcher sendScrollEventWithType:RCTScrollEventTypeEnd reactTag:self.reactTag scrollView:scrollView userData:nil]; + RCT_FORWARD_SCROLL_EVENT(scrollViewWillEndDragging:scrollView withVelocity:velocity targetContentOffset:targetContentOffset); +} + +- (void)scrollViewDidEndDragging:(UIScrollView *)scrollView willDecelerate:(BOOL)decelerate +{ + RCT_FORWARD_SCROLL_EVENT(scrollViewDidEndDragging:scrollView willDecelerate:decelerate); +} + +- (void)scrollViewWillBeginZooming:(UIScrollView *)scrollView withView:(UIView *)view +{ + [_eventDispatcher sendScrollEventWithType:RCTScrollEventTypeStart reactTag:self.reactTag scrollView:scrollView userData:nil]; + RCT_FORWARD_SCROLL_EVENT(scrollViewWillBeginZooming:scrollView withView:view); +} + +- (void)scrollViewDidEndZooming:(UIScrollView *)scrollView withView:(UIView *)view atScale:(CGFloat)scale +{ + [_eventDispatcher sendScrollEventWithType:RCTScrollEventTypeEnd reactTag:self.reactTag scrollView:scrollView userData:nil]; + RCT_FORWARD_SCROLL_EVENT(scrollViewDidEndZooming:scrollView withView:view atScale:scale); +} + +- (BOOL)scrollViewShouldScrollToTop:(UIScrollView *)scrollView +{ + if ([_nativeMainScrollDelegate respondsToSelector:_cmd]) { + return [_nativeMainScrollDelegate scrollViewShouldScrollToTop:scrollView]; + } + return YES; +} + +- (UIView *)viewForZoomingInScrollView:(UIScrollView *)scrollView +{ + return _contentView; +} + +#pragma mark - Setters + +- (CGSize)_calculateViewportSize +{ + CGSize viewportSize = self.bounds.size; + if (_automaticallyAdjustContentInsets) { + UIEdgeInsets contentInsets = [RCTView contentInsetsForView:self]; + viewportSize = CGSizeMake(self.bounds.size.width - contentInsets.left - contentInsets.right, + self.bounds.size.height - contentInsets.top - contentInsets.bottom); + } + return viewportSize; +} + +- (CGPoint)calculateOffsetForContentSize:(CGSize)newContentSize +{ + CGPoint oldOffset = _scrollView.contentOffset; + CGPoint newOffset = oldOffset; + + CGSize oldContentSize = _scrollView.contentSize; + CGSize viewportSize = [self _calculateViewportSize]; + + BOOL fitsinViewportY = oldContentSize.height <= viewportSize.height && newContentSize.height <= viewportSize.height; + if (newContentSize.height < oldContentSize.height && !fitsinViewportY) { + CGFloat offsetHeight = oldOffset.y + viewportSize.height; + if (oldOffset.y < 0) { + // overscrolled on top, leave offset alone + } else if (offsetHeight > oldContentSize.height) { + // overscrolled on the bottom, preserve overscroll amount + newOffset.y = MAX(0, oldOffset.y - (oldContentSize.height - newContentSize.height)); + } else if (offsetHeight > newContentSize.height) { + // offset falls outside of bounds, scroll back to end of list + newOffset.y = MAX(0, newContentSize.height - viewportSize.height); + } + } + + BOOL fitsinViewportX = oldContentSize.width <= viewportSize.width && newContentSize.width <= viewportSize.width; + if (newContentSize.width < oldContentSize.width && !fitsinViewportX) { + CGFloat offsetHeight = oldOffset.x + viewportSize.width; + if (oldOffset.x < 0) { + // overscrolled at the beginning, leave offset alone + } else if (offsetHeight > oldContentSize.width && newContentSize.width > viewportSize.width) { + // overscrolled at the end, preserve overscroll amount as much as possible + newOffset.x = MAX(0, oldOffset.x - (oldContentSize.width - newContentSize.width)); + } else if (offsetHeight > newContentSize.width) { + // offset falls outside of bounds, scroll back to end + newOffset.x = MAX(0, newContentSize.width - viewportSize.width); + } + } + + // all other cases, offset doesn't change + return newOffset; +} + +- (void)reactBridgeDidFinishTransaction +{ + if (_contentSizeManuallySet) { + _scrollView.contentSize = _contentSize; + } else if (!_contentView) { + _scrollView.contentSize = CGSizeZero; + } else { + CGSize singleSubviewSize = _contentView.frame.size; + CGPoint singleSubviewPosition = _contentView.frame.origin; + CGSize fittedSize = { + singleSubviewSize.width + singleSubviewPosition.x, + singleSubviewSize.height + singleSubviewPosition.y + }; + if (!CGSizeEqualToSize(_scrollView.contentSize, fittedSize)) { + // When contentSize is set manually, ScrollView internals will reset contentOffset to 0,0. Since + // we potentially set contentSize whenever anything in the ScrollView updates, we workaround this + // issue by manually adjusting contentOffset whenever this happens + CGPoint newOffset = [self calculateOffsetForContentSize:fittedSize]; + _scrollView.contentSize = fittedSize; + _scrollView.contentOffset = newOffset; + } + [_scrollView dockClosestSectionHeader]; + } +} + +- (BOOL)respondsToSelector:(SEL)aSelector +{ + if ([super respondsToSelector:aSelector]) { + return YES; + } + if ([NSStringFromSelector(aSelector) hasPrefix:@"set"]) { + return [_scrollView respondsToSelector:aSelector]; + } + return NO; +} + +- (void)setValue:(id)value forUndefinedKey:(NSString *)key +{ + // Pipe unrecognized properties to scrollview + [_scrollView setValue:value forKey:key]; +} + +- (id)valueForUndefinedKey:(NSString *)key +{ + // Pipe unrecognized properties from scrollview + return [_scrollView valueForKey:key]; +} + +@end diff --git a/ReactKit/Views/RCTScrollViewManager.h b/ReactKit/Views/RCTScrollViewManager.h new file mode 100644 index 0000000000..835e1c3226 --- /dev/null +++ b/ReactKit/Views/RCTScrollViewManager.h @@ -0,0 +1,8 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTViewManager.h" + +@interface RCTScrollViewManager : RCTViewManager + +@end + diff --git a/ReactKit/Views/RCTScrollViewManager.m b/ReactKit/Views/RCTScrollViewManager.m new file mode 100644 index 0000000000..5100d11866 --- /dev/null +++ b/ReactKit/Views/RCTScrollViewManager.m @@ -0,0 +1,55 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTScrollViewManager.h" + +#import "RCTConvert.h" +#import "RCTScrollView.h" + +@implementation RCTScrollViewManager + +- (UIView *)view +{ + return [[RCTScrollView alloc] initWithEventDispatcher:self.eventDispatcher]; +} + +RCT_EXPORT_VIEW_PROPERTY(alwaysBounceHorizontal) +RCT_EXPORT_VIEW_PROPERTY(alwaysBounceVertical) +RCT_EXPORT_VIEW_PROPERTY(bounces) +RCT_EXPORT_VIEW_PROPERTY(bouncesZoom) +RCT_EXPORT_VIEW_PROPERTY(canCancelContentTouches) +RCT_EXPORT_VIEW_PROPERTY(centerContent) +RCT_EXPORT_VIEW_PROPERTY(automaticallyAdjustContentInsets) +RCT_EXPORT_VIEW_PROPERTY(decelerationRate) +RCT_EXPORT_VIEW_PROPERTY(directionalLockEnabled) +RCT_EXPORT_VIEW_PROPERTY(keyboardDismissMode) +RCT_EXPORT_VIEW_PROPERTY(maximumZoomScale) +RCT_EXPORT_VIEW_PROPERTY(minimumZoomScale) +RCT_EXPORT_VIEW_PROPERTY(pagingEnabled) +RCT_EXPORT_VIEW_PROPERTY(scrollEnabled) +RCT_EXPORT_VIEW_PROPERTY(scrollsToTop) +RCT_EXPORT_VIEW_PROPERTY(showsHorizontalScrollIndicator) +RCT_EXPORT_VIEW_PROPERTY(showsVerticalScrollIndicator) +RCT_EXPORT_VIEW_PROPERTY(stickyHeaderIndices); +RCT_EXPORT_VIEW_PROPERTY(throttleScrollCallbackMS); +RCT_EXPORT_VIEW_PROPERTY(zoomScale); +RCT_EXPORT_VIEW_PROPERTY(contentInset); +RCT_EXPORT_VIEW_PROPERTY(scrollIndicatorInsets); +RCT_EXPORT_VIEW_PROPERTY(contentOffset); + ++ (NSDictionary *)constantsToExport +{ + return + @{ + @"DecelerationRate": @{ + @"Normal": @(UIScrollViewDecelerationRateNormal), + @"Fast": @(UIScrollViewDecelerationRateFast), + }, + @"KeyboardDismissMode": @{ + @"None": @(UIScrollViewKeyboardDismissModeNone), + @"Interactive": @(UIScrollViewKeyboardDismissModeInteractive), + @"OnDrag": @(UIScrollViewKeyboardDismissModeOnDrag), + }, + }; +} + +@end diff --git a/ReactKit/Views/RCTShadowRawText.h b/ReactKit/Views/RCTShadowRawText.h new file mode 100644 index 0000000000..d1bdc7d725 --- /dev/null +++ b/ReactKit/Views/RCTShadowRawText.h @@ -0,0 +1,9 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTShadowView.h" + +@interface RCTShadowRawText : RCTShadowView + +@property (nonatomic, copy) NSString *text; + +@end diff --git a/ReactKit/Views/RCTShadowRawText.m b/ReactKit/Views/RCTShadowRawText.m new file mode 100644 index 0000000000..370c066955 --- /dev/null +++ b/ReactKit/Views/RCTShadowRawText.m @@ -0,0 +1,16 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTShadowRawText.h" + +@implementation RCTShadowRawText + +- (void)setText:(NSString *)text +{ + if (_text != text) { + _text = [text copy]; + [self dirtyLayout]; + [self dirtyText]; + } +} + +@end diff --git a/ReactKit/Views/RCTShadowText.h b/ReactKit/Views/RCTShadowText.h new file mode 100644 index 0000000000..ddaf2ebe7f --- /dev/null +++ b/ReactKit/Views/RCTShadowText.h @@ -0,0 +1,26 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTShadowView.h" + +extern NSString *const RCTIsHighlightedAttributeName; +extern NSString *const RCTReactTagAttributeName; + +@interface RCTShadowText : RCTShadowView + +@property (nonatomic, assign) NSWritingDirection writingDirection; +@property (nonatomic, strong) UIColor *textBackgroundColor; +@property (nonatomic, strong) UIColor *color; +@property (nonatomic, strong) UIFont *font; +@property (nonatomic, copy) NSString *fontFamily; +@property (nonatomic, assign) CGFloat fontSize; +@property (nonatomic, copy) NSString *fontWeight; +@property (nonatomic, assign) BOOL isHighlighted; +@property (nonatomic, assign) CGFloat lineHeight; +@property (nonatomic, assign) NSInteger maxNumberOfLines; +@property (nonatomic, assign) CGSize shadowOffset; +@property (nonatomic, assign) NSTextAlignment textAlign; +@property (nonatomic, assign) NSLineBreakMode truncationMode; + +- (NSAttributedString *)attributedString; + +@end diff --git a/ReactKit/Views/RCTShadowText.m b/ReactKit/Views/RCTShadowText.m new file mode 100644 index 0000000000..ce6ff244e4 --- /dev/null +++ b/ReactKit/Views/RCTShadowText.m @@ -0,0 +1,199 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTShadowText.h" + +#import "RCTConvert.h" +#import "RCTLog.h" + +#import "RCTShadowRawText.h" +#import "RCTUtils.h" + +NSString *const RCTIsHighlightedAttributeName = @"IsHighlightedAttributeName"; +NSString *const RCTReactTagAttributeName = @"ReactTagAttributeName"; + +static css_dim_t RCTMeasure(void *context, float width) +{ + RCTShadowText *shadowText = (__bridge RCTShadowText *)context; + CGSize computedSize = [[shadowText attributedString] boundingRectWithSize:(CGSize){isnan(width) ? CGFLOAT_MAX : width, CGFLOAT_MAX} options:NSStringDrawingUsesLineFragmentOrigin context:nil].size; + + css_dim_t result; + result.dimensions[CSS_WIDTH] = RCTCeilPixelValue(computedSize.width); + result.dimensions[CSS_HEIGHT] = RCTCeilPixelValue(computedSize.height); + return result; +} + +@implementation RCTShadowText +{ + NSAttributedString *_cachedAttributedString; + UIFont *_font; +} + +- (instancetype)init +{ + if ((self = [super init])) { + _fontSize = NAN; + _isHighlighted = NO; + } + return self; +} + +- (NSAttributedString *)attributedString +{ + return [self _attributedStringWithFontFamily:nil + fontSize:0 + fontWeight:nil]; +} + +- (NSAttributedString *)_attributedStringWithFontFamily:(NSString *)fontFamily + fontSize:(CGFloat)fontSize + fontWeight:(NSString *)fontWeight +{ + if (![self isTextDirty] && _cachedAttributedString) { + return _cachedAttributedString; + } + + if (_fontSize && !isnan(_fontSize)) { + fontSize = _fontSize; + } + if (_fontWeight) { + fontWeight = _fontWeight; + } + if (_fontFamily) { + fontFamily = _fontFamily; + } + + NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] init]; + for (RCTShadowView *child in [self reactSubviews]) { + if ([child isKindOfClass:[RCTShadowText class]]) { + RCTShadowText *shadowText = (RCTShadowText *)child; + [attributedString appendAttributedString:[shadowText _attributedStringWithFontFamily:fontFamily fontSize:fontSize fontWeight:fontWeight]]; + } else if ([child isKindOfClass:[RCTShadowRawText class]]) { + RCTShadowRawText *shadowRawText = (RCTShadowRawText *)child; + [attributedString appendAttributedString:[[NSAttributedString alloc] initWithString:[shadowRawText text] ?: @""]]; + } else { + RCTLogError(@" can't have any children except or raw strings"); + } + + [child setTextComputed]; + } + + if (_color) { + [self _addAttribute:NSForegroundColorAttributeName withValue:self.color toAttributedString:attributedString]; + } + if (_isHighlighted) { + [self _addAttribute:RCTIsHighlightedAttributeName withValue:@YES toAttributedString:attributedString]; + } + if (_textBackgroundColor) { + [self _addAttribute:NSBackgroundColorAttributeName withValue:self.textBackgroundColor toAttributedString:attributedString]; + } + + _font = [RCTConvert UIFont:nil withFamily:fontFamily size:@(fontSize) weight:fontWeight]; + [self _addAttribute:NSFontAttributeName withValue:_font toAttributedString:attributedString]; + [self _addAttribute:RCTReactTagAttributeName withValue:self.reactTag toAttributedString:attributedString]; + [self _setParagraphStyleOnAttributedString:attributedString]; + + // create a non-mutable attributedString for use by the Text system which avoids copies down the line + _cachedAttributedString = [[NSAttributedString alloc] initWithAttributedString:attributedString]; + [self dirtyLayout]; + + return _cachedAttributedString; +} + +- (UIFont *)font +{ + return _font ?: [RCTConvert UIFont:nil withFamily:_fontFamily size:@(_fontSize) weight:_fontWeight]; +} + +- (void)_addAttribute:(NSString *)attribute withValue:(id)attributeValue toAttributedString:(NSMutableAttributedString *)attributedString +{ + [attributedString enumerateAttribute:attribute inRange:NSMakeRange(0, [attributedString length]) options:0 usingBlock:^(id value, NSRange range, BOOL *stop) { + if (!value) { + [attributedString addAttribute:attribute value:attributeValue range:range]; + } + }]; +} + +/* + * LineHeight works the same way line-height works in the web: if children and self have + * varying lineHeights, we simply take the max. + */ +- (void)_setParagraphStyleOnAttributedString:(NSMutableAttributedString *)attributedString +{ + // check if we have lineHeight set on self + __block BOOL hasParagraphStyle = NO; + if (_lineHeight || _textAlign) { + hasParagraphStyle = YES; + } + + if (!_lineHeight) { + self.lineHeight = 0.0; + } + + // check for lineHeight on each of our children, update the max as we go (in self.lineHeight) + [attributedString enumerateAttribute:NSParagraphStyleAttributeName inRange:NSMakeRange(0, [attributedString length]) options:0 usingBlock:^(id value, NSRange range, BOOL *stop) { + if (value) { + NSParagraphStyle *paragraphStyle = (NSParagraphStyle *)value; + if ([paragraphStyle maximumLineHeight] > _lineHeight) { + self.lineHeight = [paragraphStyle maximumLineHeight]; + } + hasParagraphStyle = YES; + } + }]; + + // TODO: umm, these can'e be null, so we're mapping left to natural - is that right? + self.textAlign = _textAlign ?: NSTextAlignmentNatural; + self.writingDirection = _writingDirection ?: NSWritingDirectionNatural; + + // if we found anything, set it :D + if (hasParagraphStyle) { + NSMutableParagraphStyle *paragraphStyle = [[NSMutableParagraphStyle alloc] init]; + paragraphStyle.alignment = _textAlign; + paragraphStyle.baseWritingDirection = _writingDirection; + paragraphStyle.minimumLineHeight = _lineHeight; + paragraphStyle.maximumLineHeight = _lineHeight; + [attributedString addAttribute:NSParagraphStyleAttributeName + value:paragraphStyle + range:(NSRange){0, attributedString.length}]; + } +} + +- (void)fillCSSNode:(css_node_t *)node +{ + [super fillCSSNode:node]; + node->measure = RCTMeasure; + node->children_count = 0; +} + +- (void)insertReactSubview:(RCTShadowView *)subview atIndex:(NSInteger)atIndex +{ + [super insertReactSubview:subview atIndex:atIndex]; + [self cssNode]->children_count = 0; +} + +- (void)removeReactSubview:(RCTShadowView *)subview +{ + [super removeReactSubview:subview]; + [self cssNode]->children_count = 0; +} + +#define RCT_TEXT_PROPERTY(setProp, ivar, type) \ +- (void)set##setProp:(type)value; \ +{ \ + ivar = value; \ + [self dirtyText]; \ +} + +RCT_TEXT_PROPERTY(TextBackgroundColor, _textBackgroundColor, UIColor *); +RCT_TEXT_PROPERTY(Color, _color, UIColor *); +RCT_TEXT_PROPERTY(FontFamily, _fontFamily, NSString *); +RCT_TEXT_PROPERTY(FontSize, _fontSize, CGFloat); +RCT_TEXT_PROPERTY(FontWeight, _fontWeight, NSString *); +RCT_TEXT_PROPERTY(LineHeight, _lineHeight, CGFloat); +RCT_TEXT_PROPERTY(MaxNumberOfLines, _maxNumberOfLines, NSInteger); +RCT_TEXT_PROPERTY(ShadowOffset, _shadowOffset, CGSize); +RCT_TEXT_PROPERTY(TextAlign, _textAlign, NSTextAlignment); +RCT_TEXT_PROPERTY(TruncationMode, _truncationMode, NSLineBreakMode); +RCT_TEXT_PROPERTY(IsHighlighted, _isHighlighted, BOOL); +RCT_TEXT_PROPERTY(Font, _font, UIFont *); + +@end diff --git a/ReactKit/Views/RCTShadowView.h b/ReactKit/Views/RCTShadowView.h new file mode 100644 index 0000000000..ec2b8c3537 --- /dev/null +++ b/ReactKit/Views/RCTShadowView.h @@ -0,0 +1,159 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import + +#import "Layout.h" +#import "RCTViewNodeProtocol.h" + +@class RCTSparseArray; + +// TODO: amalgamate these enums? +typedef NS_ENUM(NSUInteger, RCTLayoutLifecycle) { + RCTLayoutLifecycleUninitialized = 0, + RCTLayoutLifecycleComputed, + RCTLayoutLifecycleDirtied, +}; + +// TODO: is this still needed? +typedef NS_ENUM(NSUInteger, RCTPropagationLifecycle) { + RCTPropagationLifecycleUninitialized = 0, + RCTPropagationLifecycleComputed, + RCTPropagationLifecycleDirtied, +}; + +// TODO: move this to text node? +typedef NS_ENUM(NSUInteger, RCTTextLifecycle) { + RCTTextLifecycleUninitialized = 0, + RCTTextLifecycleComputed, + RCTTextLifecycleDirtied, +}; + +// TODO: is this redundact now? +typedef void (^RCTApplierBlock)(RCTSparseArray *); + +/** + * ShadowView tree mirrors RCT view tree. Every node is highly stateful. + * 1. A node is in one of three lifecycles: uninitialized, computed, dirtied. + * 1. RCTBridge may call any of the padding/margin/width/height/top/left setters. A setter would dirty + * the node and all of its ancestors. + * 2. At the end of each Bridge transaction, we call collectUpdatedFrames:widthConstraint:heightConstraint + * at the root node to recursively lay out the entire hierarchy. + * 3. If a node is "computed" and the constraint passed from above is identical to the constraint used to + * perform the last computation, we skip laying out the subtree entirely. + */ +@interface RCTShadowView : NSObject + +@property (nonatomic, weak, readonly) RCTShadowView *superview; +@property (nonatomic, assign, readonly) css_node_t *cssNode; +@property (nonatomic, copy) NSString *moduleName; +@property (nonatomic, assign) BOOL isBGColorExplicitlySet; // Used to propogate to children +@property (nonatomic, strong) UIColor *backgroundColor; // Used to propogate to children +@property (nonatomic, assign) RCTLayoutLifecycle layoutLifecycle; + +/** + * isNewView - Used to track the first time the view is introduced into the hierarchy. It is initialized YES, then is + * set to NO in RCTUIManager after the layout pass is done and all frames have been extracted to be applied to the + * corresponding UIViews. + */ +@property (nonatomic, assign, getter=isNewView) BOOL newView; + +/** + * Is this the shadowView for an RCTRootView + */ +@property (nonatomic, assign, getter=isReactRootView) BOOL reactRootView; + +/** + * Position and dimensions. + * Defaults to { 0, 0, NAN, NAN }. + */ +@property (nonatomic, assign) CGFloat top; +@property (nonatomic, assign) CGFloat left; +@property (nonatomic, assign) CGFloat width; +@property (nonatomic, assign) CGFloat height; +@property (nonatomic, assign) CGRect frame; + +- (void)setTopLeft:(CGPoint)topLeft; +- (void)setSize:(CGSize)size; + +/** + * Border. Defaults to { 0, 0, 0, 0 }. + */ +@property (nonatomic, assign) CGFloat borderTop; +@property (nonatomic, assign) CGFloat borderLeft; +@property (nonatomic, assign) CGFloat borderWidth; +@property (nonatomic, assign) CGFloat borderHeight; + +- (void)setBorderWidth:(CGFloat)value; + +/** + * Margin. Defaults to { 0, 0, 0, 0 }. + */ +@property (nonatomic, assign) CGFloat marginTop; +@property (nonatomic, assign) CGFloat marginLeft; +@property (nonatomic, assign) CGFloat marginBottom; +@property (nonatomic, assign) CGFloat marginRight; + +- (void)setMargin:(CGFloat)margin; +- (void)setMarginVertical:(CGFloat)margin; +- (void)setMarginHorizontal:(CGFloat)margin; + +/** + * Padding. Defaults to { 0, 0, 0, 0 }. + */ +@property (nonatomic, assign) CGFloat paddingTop; +@property (nonatomic, assign) CGFloat paddingLeft; +@property (nonatomic, assign) CGFloat paddingBottom; +@property (nonatomic, assign) CGFloat paddingRight; + +- (void)setPadding:(CGFloat)padding; +- (void)setPaddingVertical:(CGFloat)padding; +- (void)setPaddingHorizontal:(CGFloat)padding; + +- (UIEdgeInsets)paddingAsInsets; + +/** + * Flexbox properties. All zero/disabled by default + */ +@property (nonatomic, assign) css_flex_direction_t flexDirection; +@property (nonatomic, assign) css_justify_t justifyContent; +@property (nonatomic, assign) css_align_t alignSelf; +@property (nonatomic, assign) css_align_t alignItems; +@property (nonatomic, assign) css_position_type_t positionType; +@property (nonatomic, assign) css_wrap_type_t flexWrap; +@property (nonatomic, assign) CGFloat flex; + +- (void)collectUpdatedProperties:(NSMutableSet *)viewsWithNewProperties parentProperties:(NSDictionary *)parentProperties; +- (void)collectRootUpdatedFrames:(NSMutableSet *)viewsWithNewFrame parentConstraint:(CGSize)parentConstraint; +- (void)fillCSSNode:(css_node_t *)node; + +// The following are implementation details exposed to subclasses. Do not call them directly +- (void)dirtyLayout; +- (BOOL)isLayoutDirty; + +// TODO: is this still needed? +- (void)dirtyPropagation; +- (BOOL)isPropagationDirty; + +// TODO: move this to text node? +- (void)dirtyText; +- (BOOL)isTextDirty; +- (void)setTextComputed; + +/** + * Triggers a recalculation of the shadow view's layout. + */ +- (void)updateShadowViewLayout; + +/** + * Computes the recursive offset, meaning the sum of all descendant offsets - + * this is the sum of all positions inset from parents. This is not merely the + * sum of `top`/`left`s, as this function uses the *actual* positions of + * children, not the style specified positions - it computes this based on the + * resulting layout. It does not yet compensate for native scroll view insets or + * transforms or anchor points. Returns an array containing the `x, y, width, + * height` of the shadow view relative to the ancestor, or `nil` if the `view` + * is not a descendent of `ancestor`. + */ ++ (CGRect)measureLayout:(RCTShadowView *)view relativeTo:(RCTShadowView *)ancestor; + +@end diff --git a/ReactKit/Views/RCTShadowView.m b/ReactKit/Views/RCTShadowView.m new file mode 100644 index 0000000000..4877b0d676 --- /dev/null +++ b/ReactKit/Views/RCTShadowView.m @@ -0,0 +1,572 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTShadowView.h" + +#import "RCTConvert.h" +#import "RCTLog.h" +#import "RCTSparseArray.h" +#import "RCTUtils.h" + +typedef void (^RCTActionBlock)(RCTShadowView *shadowViewSelf, id value); +typedef void (^RCTResetActionBlock)(RCTShadowView *shadowViewSelf); + +@interface RCTLayoutAction : NSObject + +@property (nonatomic, readwrite, copy) RCTActionBlock block; +@property (nonatomic, readwrite, copy) RCTResetActionBlock resetBlock; +@property (nonatomic, readwrite, assign) NSInteger precedence; + +@end + +@implementation RCTLayoutAction @end + +#define ACTION_FOR_KEY_DEFAULT(name, default, blockIn) \ +do { \ + RCTLayoutAction *action = [[RCTLayoutAction alloc] init]; \ + action.block = blockIn; \ + action.resetBlock = ^(id idSelf1) { \ + blockIn(idSelf1, default); \ + }; \ + actions[@"" #name ""] = action; \ +} while(0) + +#define ACTION_FOR_KEY(name, blockIn) \ +ACTION_FOR_KEY_DEFAULT(name, @([defaultShadowView name]), (blockIn)) + +#define ACTION_FOR_FLOAT_KEY_DEFAULT(name, default, blockIn) \ +ACTION_FOR_KEY_DEFAULT(name, @(default), ^(id idSelf2, NSNumber *n) { \ + if (isnan([n floatValue])) { \ + RCTLogWarn(@"Got NaN for `"#name"` prop, ignoring"); \ + return; \ + } \ + blockIn(idSelf2, RCTNumberToFloat(n)); \ +}); + +#define ACTION_FOR_FLOAT_KEY(name, blockIn) \ +ACTION_FOR_FLOAT_KEY_DEFAULT(name, [defaultShadowView name], (blockIn)) + +#define ACTION_FOR_DEFAULT_UNDEFINED_KEY(name, blockIn) \ +ACTION_FOR_KEY_DEFAULT(name, nil, ^(id idSelf2, NSNumber *n) { \ + blockIn(idSelf2, n == nil ? CSS_UNDEFINED : [n floatValue]); \ +}); + +#define MAX_TREE_DEPTH 30 + +const NSString *const RCTBackgroundColorProp = @"backgroundColor"; + +typedef enum { + META_PROP_LEFT, + META_PROP_TOP, + META_PROP_RIGHT, + META_PROP_BOTTOM, + META_PROP_HORIZONTAL, + META_PROP_VERTICAL, + META_PROP_ALL, + META_PROP_COUNT, +} meta_prop_t; + +@interface RCTShadowView() +{ + float _paddingMetaProps[META_PROP_COUNT]; + float _marginMetaProps[META_PROP_COUNT]; +} + +@end + +@implementation RCTShadowView +{ + RCTPropagationLifecycle _propagationLifecycle; + RCTTextLifecycle _textLifecycle; + NSDictionary *_lastParentProperties; + NSMutableArray *_reactSubviews; + BOOL _recomputePadding; + BOOL _recomputeMargin; +} + +@synthesize reactTag = _reactTag; + +// css_node api + +static void RCTPrint(void *context) +{ + RCTShadowView *shadowView = (__bridge RCTShadowView *)context; + printf("%s(%zd), ", [[shadowView moduleName] UTF8String], [[shadowView reactTag] integerValue]); +} + +static css_node_t *RCTGetChild(void *context, int i) +{ + RCTShadowView *shadowView = (__bridge RCTShadowView *)context; + RCTShadowView *child = [shadowView reactSubviews][i]; + return child->_cssNode; +} + +static bool RCTIsDirty(void *context) +{ + RCTShadowView *shadowView = (__bridge RCTShadowView *)context; + return [shadowView isLayoutDirty]; +} + +// Enforces precedence rules, e.g. marginLeft > marginHorizontal > margin. +static void RCTProcessMetaProps(const float metaProps[META_PROP_COUNT], float style[CSS_POSITION_COUNT]) { + style[CSS_LEFT] = !isUndefined(metaProps[META_PROP_LEFT]) ? metaProps[META_PROP_LEFT] + : !isUndefined(metaProps[META_PROP_HORIZONTAL]) ? metaProps[META_PROP_HORIZONTAL] + : !isUndefined(metaProps[META_PROP_ALL]) ? metaProps[META_PROP_ALL] + : 0; + style[CSS_RIGHT] = !isUndefined(metaProps[META_PROP_RIGHT]) ? metaProps[META_PROP_RIGHT] + : !isUndefined(metaProps[META_PROP_HORIZONTAL]) ? metaProps[META_PROP_HORIZONTAL] + : !isUndefined(metaProps[META_PROP_ALL]) ? metaProps[META_PROP_ALL] + : 0; + style[CSS_TOP] = !isUndefined(metaProps[META_PROP_TOP]) ? metaProps[META_PROP_TOP] + : !isUndefined(metaProps[META_PROP_VERTICAL]) ? metaProps[META_PROP_VERTICAL] + : !isUndefined(metaProps[META_PROP_ALL]) ? metaProps[META_PROP_ALL] + : 0; + style[CSS_BOTTOM] = !isUndefined(metaProps[META_PROP_BOTTOM]) ? metaProps[META_PROP_BOTTOM] + : !isUndefined(metaProps[META_PROP_VERTICAL]) ? metaProps[META_PROP_VERTICAL] + : !isUndefined(metaProps[META_PROP_ALL]) ? metaProps[META_PROP_ALL] + : 0; +} + +- (void)fillCSSNode:(css_node_t *)node +{ + node->children_count = (int)_reactSubviews.count; +} + +- (void)applyLayoutNode:(css_node_t *)node viewsWithNewFrame:(NSMutableSet *)viewsWithNewFrame +{ + [self _applyLayoutNode:node viewsWithNewFrame:viewsWithNewFrame absolutePosition:CGPointZero]; +} + +// The absolute stuff is so that we can take into account our absolute position when rounding in order to +// snap to the pixel grid. For example, say you have the following structure: +// +// +--------+---------+--------+ +// | |+-------+| | +// | || || | +// | |+-------+| | +// +--------+---------+--------+ +// +// Say the screen width is 320 pts so the three big views will get the following x bounds from our layout system: +// {0, 106.667}, {106.667, 213.333}, {213.333, 320} +// +// Assuming screen scale is 2, these numbers must be rounded to the nearest 0.5 to fit the pixel grid: +// {0, 106.5}, {106.5, 213.5}, {213.5, 320} +// You'll notice that the three widths are 106.5, 107, 106.5. +// +// This is great for the parent views but it gets trickier when we consider rounding for the subview. +// +// When we go to round the bounds for the subview in the middle, it's relative bounds are {0, 106.667} +// which gets rounded to {0, 106.5}. This will cause the subview to be one pixel smaller than it should be. +// this is why we need to pass in the absolute position in order to do the rounding relative to the screen's +// grid rather than the view's grid. +// +// After passing in the absolutePosition of {106.667, y}, we do the following calculations: +// absoluteLeft = round(absolutePosition.x + viewPosition.left) = round(106.667 + 0) = 106.5 +// absoluteRight = round(absolutePosition.x + viewPosition.left + viewSize.left) + round(106.667 + 0 + 106.667) = 213.5 +// width = 213.5 - 106.5 = 107 +// You'll notice that this is the same width we calculated for the parent view because we've taken its position into account. + +- (void)_applyLayoutNode:(css_node_t *)node viewsWithNewFrame:(NSMutableSet *)viewsWithNewFrame absolutePosition:(CGPoint)absolutePosition +{ + if (!node->layout.should_update) { + return; + } + node->layout.should_update = false; + _layoutLifecycle = RCTLayoutLifecycleComputed; + + CGPoint absoluteTopLeft = { + RCTRoundPixelValue(absolutePosition.x + node->layout.position[CSS_LEFT]), + RCTRoundPixelValue(absolutePosition.y + node->layout.position[CSS_TOP]) + }; + + CGPoint absoluteBottomRight = { + RCTRoundPixelValue(absolutePosition.x + node->layout.position[CSS_LEFT] + node->layout.dimensions[CSS_WIDTH]), + RCTRoundPixelValue(absolutePosition.y + node->layout.position[CSS_TOP] + node->layout.dimensions[CSS_HEIGHT]) + }; + + CGRect frame = { + RCTRoundPixelValue(node->layout.position[CSS_LEFT]), + RCTRoundPixelValue(node->layout.position[CSS_TOP]), + RCTRoundPixelValue(absoluteBottomRight.x - absoluteTopLeft.x), + RCTRoundPixelValue(absoluteBottomRight.y - absoluteTopLeft.y) + }; + + if (!CGRectEqualToRect(frame, _frame)) { + _frame = frame; + [viewsWithNewFrame addObject:self]; + } + + absolutePosition.x += node->layout.position[CSS_LEFT]; + absolutePosition.y += node->layout.position[CSS_TOP]; + + node->layout.dimensions[CSS_WIDTH] = CSS_UNDEFINED; + node->layout.dimensions[CSS_HEIGHT] = CSS_UNDEFINED; + node->layout.position[CSS_LEFT] = 0; + node->layout.position[CSS_TOP] = 0; + + for (int i = 0; i < node->children_count; ++i) { + RCTShadowView *child = (RCTShadowView *)_reactSubviews[i]; + [child _applyLayoutNode:node->get_child(node->context, i) viewsWithNewFrame:viewsWithNewFrame absolutePosition:absolutePosition]; + } +} + +- (NSDictionary *)processBackgroundColor:(NSMutableSet *)applierBlocks parentProperties:(NSDictionary *)parentProperties +{ + if (!_isBGColorExplicitlySet) { + UIColor *parentBackgroundColor = parentProperties[RCTBackgroundColorProp]; + if (parentBackgroundColor && ![_backgroundColor isEqual:parentBackgroundColor]) { + _backgroundColor = parentBackgroundColor; + [applierBlocks addObject:^(RCTSparseArray *viewRegistry) { + UIView *view = viewRegistry[_reactTag]; + view.backgroundColor = parentBackgroundColor; + }]; + } + } + if (_isBGColorExplicitlySet) { + // Update parent properties for children + NSMutableDictionary *properties = [NSMutableDictionary dictionaryWithDictionary:parentProperties]; + CGFloat alpha = CGColorGetAlpha(_backgroundColor.CGColor); + if (alpha < 1.0 && alpha > 0.0) { + // If we see partial transparency, start propagating full transparency + properties[RCTBackgroundColorProp] = [UIColor clearColor]; + } else { + properties[RCTBackgroundColorProp] = _backgroundColor; + } + return properties; + } + return parentProperties; +} + +- (void)collectUpdatedProperties:(NSMutableSet *)applierBlocks parentProperties:(NSDictionary *)parentProperties +{ + if (_propagationLifecycle == RCTPropagationLifecycleComputed && [parentProperties isEqualToDictionary:_lastParentProperties]) { + return; + } + _propagationLifecycle = RCTPropagationLifecycleComputed; + _lastParentProperties = parentProperties; + NSDictionary *nextProps = [self processBackgroundColor:applierBlocks parentProperties:parentProperties]; + for (RCTShadowView *child in _reactSubviews) { + [child collectUpdatedProperties:applierBlocks parentProperties:nextProps]; + } +} + +- (void)collectRootUpdatedFrames:(NSMutableSet *)viewsWithNewFrame parentConstraint:(CGSize)parentConstraint +{ + [self fillCSSNode:_cssNode]; + layoutNode(_cssNode, CSS_UNDEFINED); + [self applyLayoutNode:_cssNode viewsWithNewFrame:viewsWithNewFrame]; +} + ++ (CGRect)measureLayout:(RCTShadowView *)shadowView relativeTo:(RCTShadowView *)ancestor +{ + CGFloat totalOffsetTop = 0.0; + CGFloat totalOffsetLeft = 0.0; + CGSize size = shadowView.frame.size; + NSInteger depth = 0; + while (depth < MAX_TREE_DEPTH && shadowView && shadowView != ancestor) { + totalOffsetTop += shadowView.frame.origin.y; + totalOffsetLeft += shadowView.frame.origin.x; + shadowView = shadowView->_superview; + depth++; + } + if (ancestor != shadowView) { + return CGRectNull; + } + return (CGRect){{totalOffsetLeft, totalOffsetTop}, size}; +} + +- (instancetype)init +{ + if ((self = [super init])) { + + _frame = CGRectMake(0, 0, CSS_UNDEFINED, CSS_UNDEFINED); + + for (int ii = 0; ii < META_PROP_COUNT; ii++) { + _paddingMetaProps[ii] = CSS_UNDEFINED; + _marginMetaProps[ii] = CSS_UNDEFINED; + } + + _newView = YES; + _layoutLifecycle = RCTLayoutLifecycleUninitialized; + _propagationLifecycle = RCTPropagationLifecycleUninitialized; + _textLifecycle = RCTTextLifecycleUninitialized; + + _reactSubviews = [NSMutableArray array]; + + _cssNode = new_css_node(); + _cssNode->context = (__bridge void *)self; + _cssNode->print = RCTPrint; + _cssNode->get_child = RCTGetChild; + _cssNode->is_dirty = RCTIsDirty; + [self fillCSSNode:_cssNode]; + } + return self; +} + +- (void)dealloc +{ + free_css_node(_cssNode); +} + +- (void)dirtyLayout +{ + if (_layoutLifecycle != RCTLayoutLifecycleDirtied) { + _layoutLifecycle = RCTLayoutLifecycleDirtied; + [_superview dirtyLayout]; + } +} + +- (BOOL)isLayoutDirty +{ + return _layoutLifecycle != RCTLayoutLifecycleComputed; +} + +- (void)dirtyPropagation +{ + if (_propagationLifecycle != RCTPropagationLifecycleDirtied) { + _propagationLifecycle = RCTPropagationLifecycleDirtied; + [_superview dirtyPropagation]; + } +} + +- (BOOL)isPropagationDirty +{ + return _propagationLifecycle != RCTLayoutLifecycleComputed; +} + +- (void)dirtyText +{ + if (_textLifecycle != RCTTextLifecycleDirtied) { + _textLifecycle = RCTTextLifecycleDirtied; + [_superview dirtyText]; + } +} + +- (BOOL)isTextDirty +{ + return _textLifecycle != RCTTextLifecycleComputed; +} + +- (void)setTextComputed +{ + _textLifecycle = RCTTextLifecycleComputed; +} + +- (void)insertReactSubview:(RCTShadowView *)subview atIndex:(NSInteger)atIndex +{ + [_reactSubviews insertObject:subview atIndex:atIndex]; + _cssNode->children_count = (int)[_reactSubviews count]; + subview->_superview = self; + [self dirtyText]; + [self dirtyLayout]; + [self dirtyPropagation]; +} + +- (void)removeReactSubview:(RCTShadowView *)subview +{ + [subview dirtyText]; + [subview dirtyLayout]; + [subview dirtyPropagation]; + subview->_superview = nil; + [_reactSubviews removeObject:subview]; + _cssNode->children_count = (int)[_reactSubviews count]; +} + +- (NSArray *)reactSubviews +{ + return _reactSubviews; +} + +- (NSNumber *)reactTagAtPoint:(CGPoint)point +{ + for (RCTShadowView *shadowView in _reactSubviews) { + if (CGRectContainsPoint(shadowView.frame, point)) { + CGPoint relativePoint = point; + CGPoint origin = shadowView.frame.origin; + relativePoint.x -= origin.x; + relativePoint.y -= origin.y; + return [shadowView reactTagAtPoint:relativePoint]; + } + } + + return self.reactTag; +} + +// Margin + +#define RCT_MARGIN_PROPERTY(prop, metaProp) \ +- (void)setMargin##prop:(CGFloat)value \ +{ \ + _marginMetaProps[META_PROP_##metaProp] = value; \ + _recomputeMargin = YES; \ +} \ +- (CGFloat)margin##prop \ +{ \ + return _marginMetaProps[META_PROP_##metaProp]; \ +} + +RCT_MARGIN_PROPERTY(, ALL) +RCT_MARGIN_PROPERTY(Vertical, VERTICAL) +RCT_MARGIN_PROPERTY(Horizontal, HORIZONTAL) +RCT_MARGIN_PROPERTY(Top, TOP) +RCT_MARGIN_PROPERTY(Left, LEFT) +RCT_MARGIN_PROPERTY(Bottom, BOTTOM) +RCT_MARGIN_PROPERTY(Right, RIGHT) + +// Padding + +#define RCT_PADDING_PROPERTY(prop, metaProp) \ +- (void)setPadding##prop:(CGFloat)value \ +{ \ + _paddingMetaProps[META_PROP_##metaProp] = value; \ + _recomputePadding = YES; \ +} \ +- (CGFloat)padding##prop \ +{ \ + return _paddingMetaProps[META_PROP_##metaProp]; \ +} + +RCT_PADDING_PROPERTY(, ALL) +RCT_PADDING_PROPERTY(Vertical, VERTICAL) +RCT_PADDING_PROPERTY(Horizontal, HORIZONTAL) +RCT_PADDING_PROPERTY(Top, TOP) +RCT_PADDING_PROPERTY(Left, LEFT) +RCT_PADDING_PROPERTY(Bottom, BOTTOM) +RCT_PADDING_PROPERTY(Right, RIGHT) + +- (UIEdgeInsets)paddingAsInsets +{ + return (UIEdgeInsets){ + _cssNode->style.padding[CSS_TOP], + _cssNode->style.padding[CSS_LEFT], + _cssNode->style.padding[CSS_BOTTOM], + _cssNode->style.padding[CSS_RIGHT] + }; +} + +// Border + +#define RCT_BORDER_PROPERTY(prop, metaProp) \ +- (void)setBorder##prop:(CGFloat)value \ +{ \ + _cssNode->style.border[CSS_##metaProp] = value; \ + [self dirtyLayout]; \ +} \ +- (CGFloat)border##prop \ +{ \ + return _cssNode->style.border[META_PROP_##metaProp]; \ +} + +RCT_BORDER_PROPERTY(Top, TOP) +RCT_BORDER_PROPERTY(Left, LEFT) +RCT_BORDER_PROPERTY(Bottom, BOTTOM) +RCT_BORDER_PROPERTY(Right, RIGHT) + +- (void)setBorderWidth:(CGFloat)value +{ + for (int i = 0; i < 4; i++) { + _cssNode->style.border[i] = value; + } + [self dirtyLayout]; +} + +// Dimensions + +#define RCT_DIMENSIONS_PROPERTY(setProp, getProp, cssProp) \ +- (void)set##setProp:(CGFloat)value \ +{ \ + _cssNode->style.dimensions[CSS_##cssProp] = value; \ + [self dirtyLayout]; \ +} \ +- (CGFloat)getProp \ +{ \ + return _cssNode->style.dimensions[CSS_##cssProp]; \ +} + +RCT_DIMENSIONS_PROPERTY(Width, width, WIDTH) +RCT_DIMENSIONS_PROPERTY(Height, height, HEIGHT) + +// Position + +#define RCT_POSITION_PROPERTY(setProp, getProp, cssProp) \ +- (void)set##setProp:(CGFloat)value \ +{ \ + _cssNode->style.position[CSS_##cssProp] = value; \ + [self dirtyLayout]; \ +} \ +- (CGFloat)getProp \ +{ \ + return _cssNode->style.position[CSS_##cssProp]; \ +} + +RCT_POSITION_PROPERTY(Top, top, TOP) +RCT_POSITION_PROPERTY(Right, right, RIGHT) +RCT_POSITION_PROPERTY(Bottom, bottom, BOTTOM) +RCT_POSITION_PROPERTY(Left, left, LEFT) + +- (void)setFrame:(CGRect)frame +{ + _cssNode->style.position[CSS_LEFT] = CGRectGetMinX(frame); + _cssNode->style.position[CSS_TOP] = CGRectGetMinY(frame); + _cssNode->style.dimensions[CSS_WIDTH] = CGRectGetWidth(frame); + _cssNode->style.dimensions[CSS_HEIGHT] = CGRectGetHeight(frame); + [self dirtyLayout]; +} + +- (void)setTopLeft:(CGPoint)topLeft +{ + _cssNode->style.position[CSS_LEFT] = topLeft.x; + _cssNode->style.position[CSS_TOP] = topLeft.y; + [self dirtyLayout]; +} + +- (void)setSize:(CGSize)size +{ + _cssNode->style.dimensions[CSS_WIDTH] = size.width; + _cssNode->style.dimensions[CSS_HEIGHT] = size.height; + [self dirtyLayout]; +} + +// Flex + +#define RCT_STYLE_PROPERTY(setProp, getProp, cssProp, type) \ +- (void)set##setProp:(type)value \ +{ \ + _cssNode->style.cssProp = value; \ + [self dirtyLayout]; \ +} \ +- (type)getProp \ +{ \ + return _cssNode->style.cssProp; \ +} + +RCT_STYLE_PROPERTY(Flex, flex, flex, CGFloat) +RCT_STYLE_PROPERTY(FlexDirection, flexDirection, flex_direction, css_flex_direction_t) +RCT_STYLE_PROPERTY(JustifyContent, justifyContent, justify_content, css_justify_t) +RCT_STYLE_PROPERTY(AlignSelf, alignSelf, align_self, css_align_t) +RCT_STYLE_PROPERTY(AlignItems, alignItems, align_items, css_align_t) +RCT_STYLE_PROPERTY(PositionType, positionType, position_type, css_position_type_t) +RCT_STYLE_PROPERTY(FlexWrap, flexWrap, flex_wrap, css_wrap_type_t) + +- (void)setBackgroundColor:(UIColor *)color +{ + _backgroundColor = color; + [self dirtyPropagation]; +} + +- (void)updateShadowViewLayout +{ + if (_recomputePadding) { + RCTProcessMetaProps(_paddingMetaProps, _cssNode->style.padding); + } + if (_recomputeMargin) { + RCTProcessMetaProps(_marginMetaProps, _cssNode->style.margin); + } + if (_recomputePadding || _recomputeMargin) { + [self dirtyLayout]; + } + [self fillCSSNode:_cssNode]; + _recomputeMargin = NO; + _recomputePadding = NO; +} + +@end diff --git a/ReactKit/Views/RCTStaticImage.h b/ReactKit/Views/RCTStaticImage.h new file mode 100644 index 0000000000..75906be39b --- /dev/null +++ b/ReactKit/Views/RCTStaticImage.h @@ -0,0 +1,10 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import + +@interface RCTStaticImage : UIImageView + +@property (nonatomic, assign) UIEdgeInsets capInsets; +@property (nonatomic, assign) UIImageRenderingMode renderingMode; + +@end diff --git a/ReactKit/Views/RCTStaticImage.m b/ReactKit/Views/RCTStaticImage.m new file mode 100644 index 0000000000..829044de56 --- /dev/null +++ b/ReactKit/Views/RCTStaticImage.m @@ -0,0 +1,51 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTStaticImage.h" + +@implementation RCTStaticImage + +- (void)_updateImage +{ + UIImage *image = self.image; + if (!image) { + return; + } + + // Apply rendering mode + if (_renderingMode != image.renderingMode) { + image = [image imageWithRenderingMode:_renderingMode]; + } + + // Applying capInsets of 0 will switch the "resizingMode" of the image to "tile" which is undesired + if (!UIEdgeInsetsEqualToEdgeInsets(UIEdgeInsetsZero, _capInsets)) { + image = [image resizableImageWithCapInsets:_capInsets resizingMode:UIImageResizingModeStretch]; + } + + super.image = image; +} + +- (void)setImage:(UIImage *)image +{ + if (image != super.image) { + super.image = image; + [self _updateImage]; + } +} + +- (void)setCapInsets:(UIEdgeInsets)capInsets +{ + if (!UIEdgeInsetsEqualToEdgeInsets(_capInsets, capInsets)) { + _capInsets = capInsets; + [self _updateImage]; + } +} + +- (void)setRenderingMode:(UIImageRenderingMode)renderingMode +{ + if (_renderingMode != renderingMode) { + _renderingMode = renderingMode; + [self _updateImage]; + } +} + +@end diff --git a/ReactKit/Views/RCTStaticImageManager.h b/ReactKit/Views/RCTStaticImageManager.h new file mode 100644 index 0000000000..ab89cb96b8 --- /dev/null +++ b/ReactKit/Views/RCTStaticImageManager.h @@ -0,0 +1,7 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTViewManager.h" + +@interface RCTStaticImageManager : RCTViewManager + +@end diff --git a/ReactKit/Views/RCTStaticImageManager.m b/ReactKit/Views/RCTStaticImageManager.m new file mode 100644 index 0000000000..2cd08e1e27 --- /dev/null +++ b/ReactKit/Views/RCTStaticImageManager.m @@ -0,0 +1,45 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTStaticImageManager.h" + +#import + +#import "RCTStaticImage.h" +#import "RCTConvert.h" + +@implementation RCTStaticImageManager + +- (UIView *)view +{ + return [[RCTStaticImage alloc] init]; +} + +RCT_EXPORT_VIEW_PROPERTY(capInsets) +RCT_REMAP_VIEW_PROPERTY(resizeMode, contentMode) + +- (void)set_src:(id)json forView:(RCTStaticImage *)view withDefaultView:(RCTStaticImage *)defaultView +{ + if (json) { + if ([[[json description] pathExtension] caseInsensitiveCompare:@"gif"] == NSOrderedSame) { + [view.layer addAnimation:[RCTConvert GIF:json] forKey:@"contents"]; + } else { + view.image = [RCTConvert UIImage:json]; + } + } else { + view.image = defaultView.image; + } +} + +- (void)set_tintColor:(id)json forView:(RCTStaticImage *)view withDefaultView:(RCTStaticImage *)defaultView +{ + if (json) { + view.renderingMode = UIImageRenderingModeAlwaysTemplate; + view.tintColor = [RCTConvert UIColor:json]; + } else { + view.renderingMode = defaultView.renderingMode; + view.tintColor = defaultView.tintColor; + } +} + +@end + diff --git a/ReactKit/Views/RCTText.h b/ReactKit/Views/RCTText.h new file mode 100644 index 0000000000..80ba3f2341 --- /dev/null +++ b/ReactKit/Views/RCTText.h @@ -0,0 +1,13 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import + +@interface RCTText : UIView + +@property (nonatomic, copy) NSAttributedString *attributedText; +@property (nonatomic, assign) NSLineBreakMode lineBreakMode; +@property (nonatomic, assign) NSUInteger numberOfLines; + +- (NSNumber *)reactTagAtPoint:(CGPoint)point; + +@end diff --git a/ReactKit/Views/RCTText.m b/ReactKit/Views/RCTText.m new file mode 100644 index 0000000000..f0b9138799 --- /dev/null +++ b/ReactKit/Views/RCTText.m @@ -0,0 +1,100 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTText.h" + +#import "RCTShadowText.h" +#import "RCTUtils.h" +#import "UIView+ReactKit.h" + +@implementation RCTText +{ + NSLayoutManager *_layoutManager; + NSTextStorage *_textStorage; + NSTextContainer *_textContainer; +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + if ((self = [super initWithFrame:frame])) { + _textContainer = [[NSTextContainer alloc] init]; + _textContainer.lineBreakMode = NSLineBreakByTruncatingTail; + _textContainer.lineFragmentPadding = 0.0; + + _layoutManager = [[NSLayoutManager alloc] init]; + [_layoutManager addTextContainer:_textContainer]; + + _textStorage = [[NSTextStorage alloc] init]; + [_textStorage addLayoutManager:_layoutManager]; + + self.contentMode = UIViewContentModeRedraw; + } + + return self; +} + +- (NSAttributedString *)attributedText +{ + return [_textStorage copy]; +} + +- (void)setAttributedText:(NSAttributedString *)attributedText +{ + [_textStorage setAttributedString:attributedText]; + [self setNeedsDisplay]; +} + +- (NSUInteger)numberOfLines +{ + return _textContainer.maximumNumberOfLines; +} + +- (void)setNumberOfLines:(NSUInteger)numberOfLines +{ + _textContainer.maximumNumberOfLines = numberOfLines; + [self setNeedsDisplay]; +} + +- (NSLineBreakMode)lineBreakMode +{ + return _textContainer.lineBreakMode; +} + +- (void)setLineBreakMode:(NSLineBreakMode)lineBreakMode +{ + _textContainer.lineBreakMode = lineBreakMode; + [self setNeedsDisplay]; +} + +- (void)layoutSubviews +{ + [super layoutSubviews]; + + // The header comment for `size` says that a height of 0.0 should be enough, + // but it isn't. + _textContainer.size = CGSizeMake(self.bounds.size.width, CGFLOAT_MAX); +} + +- (void)drawRect:(CGRect)rect +{ + NSRange glyphRange = [_layoutManager glyphRangeForTextContainer:_textContainer]; + [_layoutManager drawBackgroundForGlyphRange:glyphRange atPoint:CGPointZero]; + [_layoutManager drawGlyphsForGlyphRange:glyphRange atPoint:CGPointZero]; +} + +- (NSNumber *)reactTagAtPoint:(CGPoint)point +{ + CGFloat fraction; + NSUInteger characterIndex = [_layoutManager characterIndexForPoint:point inTextContainer:_textContainer fractionOfDistanceBetweenInsertionPoints:&fraction]; + + NSNumber *reactTag = nil; + + // If the point is not before (fraction == 0.0) the first character and not + // after (fraction == 1.0) the last character, then the attribute is valid. + if (_textStorage.length > 0 && (fraction > 0 || characterIndex > 0) && (fraction < 1 || characterIndex < _textStorage.length - 1)) { + reactTag = [_textStorage attribute:RCTReactTagAttributeName atIndex:characterIndex effectiveRange:NULL]; + } + + return reactTag ?: self.reactTag; +} + +@end diff --git a/ReactKit/Views/RCTTextField.h b/ReactKit/Views/RCTTextField.h new file mode 100644 index 0000000000..2a0225f276 --- /dev/null +++ b/ReactKit/Views/RCTTextField.h @@ -0,0 +1,15 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import + +@class RCTEventDispatcher; + +@interface RCTTextField : UITextField + +@property (nonatomic, assign) BOOL caretHidden; +@property (nonatomic, assign) BOOL autoCorrect; +@property (nonatomic, assign) UIEdgeInsets paddingEdgeInsets; // TODO: contentInset + +- (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher; + +@end diff --git a/ReactKit/Views/RCTTextField.m b/ReactKit/Views/RCTTextField.m new file mode 100644 index 0000000000..4a70e04600 --- /dev/null +++ b/ReactKit/Views/RCTTextField.m @@ -0,0 +1,131 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTTextField.h" + +#import "RCTConvert.h" +#import "RCTEventDispatcher.h" +#import "RCTUtils.h" +#import "UIView+ReactKit.h" + +@implementation RCTTextField +{ + RCTEventDispatcher *_eventDispatcher; + NSMutableArray *_reactSubviews; + BOOL _jsRequestingFirstResponder; +} + +- (instancetype)initWithFrame:(CGRect)frame +{ + RCT_NOT_DESIGNATED_INITIALIZER(); +} + +- (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher +{ + if ((self = [super initWithFrame:CGRectZero])) { + + _eventDispatcher = eventDispatcher; + [self addTarget:self action:@selector(_textFieldDidChange) forControlEvents:UIControlEventEditingChanged]; + [self addTarget:self action:@selector(_textFieldBeginEditing) forControlEvents:UIControlEventEditingDidBegin]; + [self addTarget:self action:@selector(_textFieldEndEditing) forControlEvents:UIControlEventEditingDidEnd]; + [self addTarget:self action:@selector(_textFieldSubmitEditing) forControlEvents:UIControlEventEditingDidEndOnExit]; + _reactSubviews = [[NSMutableArray alloc] init]; + self.returnKeyType = UIReturnKeyDone; + } + return self; +} + +- (NSArray *)reactSubviews +{ + // TODO: do we support subviews of textfield in React? + // In any case, we should have a better approach than manually + // maintaining array in each view subclass like this + return _reactSubviews; +} + +- (void)removeReactSubview:(UIView *)subview +{ + // TODO: this is a bit broken - if the TextView inserts any of + // it's own views below or between React's, the indices won't match + [_reactSubviews removeObject:subview]; + [subview removeFromSuperview]; +} + +- (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex +{ + // TODO: this is a bit broken - if the TextView inserts any of + // it's own views below or between React's, the indices won't match + [_reactSubviews insertObject:view atIndex:atIndex]; + [super insertSubview:view atIndex:atIndex]; +} + +- (CGRect)caretRectForPosition:(UITextPosition *)position +{ + if (_caretHidden) { + return CGRectZero; + } + return [super caretRectForPosition:position]; +} + +- (CGRect)textRectForBounds:(CGRect)bounds +{ + CGRect rect = [super textRectForBounds:bounds]; + return UIEdgeInsetsInsetRect(rect, _paddingEdgeInsets); +} + +- (CGRect)editingRectForBounds:(CGRect)bounds +{ + return [self textRectForBounds:bounds]; +} + +- (void)setAutoCorrect:(BOOL)autoCorrect +{ + [super setAutocorrectionType:(autoCorrect ? UITextAutocorrectionTypeYes : UITextAutocorrectionTypeNo)]; +} + +- (BOOL)autoCorrect +{ + return self.autocorrectionType == UITextAutocorrectionTypeYes; +} + +#define RCT_TEXT_EVENT_HANDLER(delegateMethod, eventName) \ +- (void)delegateMethod \ +{ \ + [_eventDispatcher sendTextEventWithType:eventName \ + reactTag:self.reactTag \ + text:self.text]; \ +} + +RCT_TEXT_EVENT_HANDLER(_textFieldDidChange, RCTTextEventTypeChange) +RCT_TEXT_EVENT_HANDLER(_textFieldBeginEditing, RCTTextEventTypeFocus) +RCT_TEXT_EVENT_HANDLER(_textFieldEndEditing, RCTTextEventTypeEnd) +RCT_TEXT_EVENT_HANDLER(_textFieldSubmitEditing, RCTTextEventTypeSubmit) + +// TODO: we should support shouldChangeTextInRect (see UITextFieldDelegate) + +- (BOOL)becomeFirstResponder +{ + _jsRequestingFirstResponder = YES; // TODO: is this still needed? + BOOL result = [super becomeFirstResponder]; + _jsRequestingFirstResponder = NO; + return result; +} + +- (BOOL)resignFirstResponder +{ + BOOL result = [super resignFirstResponder]; + if (result) + { + [_eventDispatcher sendTextEventWithType:RCTTextEventTypeBlur + reactTag:self.reactTag + text:self.text]; + } + return result; +} + +// Prevent native from becoming first responder (TODO: why?) +- (BOOL)canBecomeFirstResponder +{ + return _jsRequestingFirstResponder; +} + +@end diff --git a/ReactKit/Views/RCTTextFieldManager.h b/ReactKit/Views/RCTTextFieldManager.h new file mode 100644 index 0000000000..1f83a47d8f --- /dev/null +++ b/ReactKit/Views/RCTTextFieldManager.h @@ -0,0 +1,8 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTViewManager.h" + +@interface RCTTextFieldManager : RCTViewManager + +@end + diff --git a/ReactKit/Views/RCTTextFieldManager.m b/ReactKit/Views/RCTTextFieldManager.m new file mode 100644 index 0000000000..339a7803e1 --- /dev/null +++ b/ReactKit/Views/RCTTextFieldManager.m @@ -0,0 +1,51 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTTextFieldManager.h" + +#import "RCTConvert.h" +#import "RCTShadowView.h" +#import "RCTTextField.h" + +@implementation RCTTextFieldManager + +- (UIView *)view +{ + return [[RCTTextField alloc] initWithEventDispatcher:self.eventDispatcher]; +} + +RCT_EXPORT_VIEW_PROPERTY(caretHidden) +RCT_EXPORT_VIEW_PROPERTY(autoCorrect) +RCT_EXPORT_VIEW_PROPERTY(enabled) +RCT_EXPORT_VIEW_PROPERTY(placeholder) +RCT_EXPORT_VIEW_PROPERTY(text) +RCT_EXPORT_VIEW_PROPERTY(font) +RCT_REMAP_VIEW_PROPERTY(autoCapitalize, autocapitalizationType) +RCT_EXPORT_VIEW_PROPERTY(keyboardType) +RCT_REMAP_VIEW_PROPERTY(color, textColor) + +- (void)set_fontSize:(id)json + forView:(RCTTextField *)view + withDefaultView:(RCTTextField *)defaultView +{ + view.font = [RCTConvert UIFont:view.font withSize:json ?: @(defaultView.font.pointSize)]; +} + +- (void)set_FontWeight:(id)json + forView:(RCTTextField *)view + withDefaultView:(RCTTextField *)defaultView +{ + view.font = [RCTConvert UIFont:view.font withWeight:json]; // TODO +} + +- (void)set_fontFamily:(id)json + forView:(RCTTextField *)view + withDefaultView:(RCTTextField *)defaultView +{ + view.font = [RCTConvert UIFont:view.font withFamily:json ?: defaultView.font.familyName]; +} + +// TODO: original code set view.paddingEdgeInsets from shadowView.paddingAsInsets +// could it be that this property is calculated asynchrously on shadow thread? + +@end + diff --git a/ReactKit/Views/RCTTextManager.h b/ReactKit/Views/RCTTextManager.h new file mode 100644 index 0000000000..0359cce409 --- /dev/null +++ b/ReactKit/Views/RCTTextManager.h @@ -0,0 +1,8 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTViewManager.h" + +@interface RCTTextManager : RCTViewManager + +@end + diff --git a/ReactKit/Views/RCTTextManager.m b/ReactKit/Views/RCTTextManager.m new file mode 100644 index 0000000000..fe1ca68202 --- /dev/null +++ b/ReactKit/Views/RCTTextManager.m @@ -0,0 +1,135 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTTextManager.h" + +#import "RCTAssert.h" +#import "RCTConvert.h" +#import "RCTLog.h" +#import "RCTShadowRawText.h" +#import "RCTShadowText.h" +#import "RCTSparseArray.h" +#import "RCTText.h" +#import "UIView+ReactKit.h" + +@implementation RCTTextManager + +- (UIView *)view +{ + return [[RCTText alloc] init]; +} + +- (RCTShadowView *)shadowView +{ + return [[RCTShadowText alloc] init]; +} + +RCT_REMAP_VIEW_PROPERTY(containerBackgroundColor, backgroundColor) + +- (void)set_textAlign:(id)json + forShadowView:(RCTShadowText *)shadowView + withDefaultView:(RCTShadowText *)defaultView +{ + shadowView.textAlign = json ? [RCTConvert NSTextAlignment:json] : defaultView.textAlign; +} + +- (void)set_numberOfLines:(id)json + forView:(RCTText *)view + withDefaultView:(RCTText *)defaultView +{ + NSLineBreakMode truncationMode = NSLineBreakByClipping; + view.numberOfLines = json ? [RCTConvert NSInteger:json] : defaultView.numberOfLines; + if (view.numberOfLines > 0) { + truncationMode = NSLineBreakByTruncatingTail; + } + view.lineBreakMode = truncationMode; +} + +- (void)set_numberOfLines:(id)json + forShadowView:(RCTShadowText *)shadowView + withDefaultView:(RCTShadowText *)defaultView +{ + NSLineBreakMode truncationMode = NSLineBreakByClipping; + shadowView.maxNumberOfLines = json ? [RCTConvert NSInteger:json] : defaultView.maxNumberOfLines; + if (shadowView.maxNumberOfLines > 0) { + truncationMode = NSLineBreakByTruncatingTail; + } + shadowView.truncationMode = truncationMode; +} + +- (void)set_backgroundColor:(id)json + forShadowView:(RCTShadowText *)shadowView + withDefaultView:(RCTShadowText *)defaultView +{ + shadowView.textBackgroundColor = json ? [RCTConvert UIColor:json] : defaultView.textBackgroundColor; +} + +- (void)set_containerBackgroundColor:(id)json + forShadowView:(RCTShadowText *)shadowView + withDefaultView:(RCTShadowText *)defaultView +{ + shadowView.backgroundColor = json ? [RCTConvert UIColor:json] : defaultView.backgroundColor; + shadowView.isBGColorExplicitlySet = json ? YES : defaultView.isBGColorExplicitlySet; +} + +// TODO: the purpose of this block is effectively just to copy properties from the shadow views +// to their equivalent UIViews. In this case, the property being copied is the attributed text, +// but the same principle could be used to copy any property. The implementation is really ugly tho +// because the RCTViewManager doesn't retain a reference to the views that it manages, so it basically +// has to search the entire view hierarchy for relevant views. Not awesome. This seems like something +// where we could introduce a generic solution - perhaps a method on RCTShadowView that is called after +// layout to copy its properties across? +- (RCTViewManagerUIBlock)uiBlockToAmendWithShadowViewRegistry:(RCTSparseArray *)shadowViewRegistry +{ + NSMutableArray *uiBlocks = [NSMutableArray new]; + + // TODO: are modules global, or specific to a given rootView? + for (RCTShadowView *rootView in shadowViewRegistry.allObjects) { + if (![rootView isReactRootView]) { + // This isn't a root view + continue; + } + + if (![rootView isTextDirty]) { + // No text processing to be done + continue; + } + + // TODO: this is a slightly weird way to do this - a recursive approach would be cleaner + RCTSparseArray *reactTaggedAttributedStrings = [[RCTSparseArray alloc] init]; + NSMutableArray *queue = [NSMutableArray arrayWithObject:rootView]; + for (NSInteger i = 0; i < [queue count]; i++) { + RCTShadowView *shadowView = queue[i]; + RCTAssert([shadowView isTextDirty], @"Don't process any nodes that don't have dirty text"); + + if ([shadowView isKindOfClass:[RCTShadowText class]]) { + RCTShadowText *shadowText = (RCTShadowText *)shadowView; + reactTaggedAttributedStrings[shadowText.reactTag] = [shadowText attributedString]; + } else if ([shadowView isKindOfClass:[RCTShadowRawText class]]) { + RCTLogError(@"Raw text cannot be used outside of a tag. Not rendering string: '%@'", [(RCTShadowRawText *)shadowView text]); + } else { + for (RCTShadowView *child in [shadowView reactSubviews]) { + if ([child isTextDirty]) { + [queue addObject:child]; + } + } + } + + [shadowView setTextComputed]; + } + + [uiBlocks addObject:^(RCTUIManager *uiManager, RCTSparseArray *viewRegistry) { + [reactTaggedAttributedStrings enumerateObjectsUsingBlock:^(NSAttributedString *attributedString, NSNumber *reactTag, BOOL *stop) { + RCTText *text = viewRegistry[reactTag]; + text.attributedText = attributedString; + }]; + }]; + } + + return ^(RCTUIManager *uiManager, RCTSparseArray *viewRegistry) { + for (RCTViewManagerUIBlock shadowBlock in uiBlocks) { + shadowBlock(uiManager, viewRegistry); + } + }; +} + +@end diff --git a/ReactKit/Views/RCTUIActivityIndicatorViewManager.h b/ReactKit/Views/RCTUIActivityIndicatorViewManager.h new file mode 100644 index 0000000000..d676613590 --- /dev/null +++ b/ReactKit/Views/RCTUIActivityIndicatorViewManager.h @@ -0,0 +1,7 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTViewManager.h" + +@interface RCTUIActivityIndicatorViewManager : RCTViewManager + +@end diff --git a/ReactKit/Views/RCTUIActivityIndicatorViewManager.m b/ReactKit/Views/RCTUIActivityIndicatorViewManager.m new file mode 100644 index 0000000000..b7e0971e73 --- /dev/null +++ b/ReactKit/Views/RCTUIActivityIndicatorViewManager.m @@ -0,0 +1,41 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTUIActivityIndicatorViewManager.h" + +#import "RCTConvert.h" + +@implementation RCTUIActivityIndicatorViewManager + +- (UIView *)view +{ + return [[UIActivityIndicatorView alloc] init]; +} + +RCT_EXPORT_VIEW_PROPERTY(activityIndicatorViewStyle) +RCT_EXPORT_VIEW_PROPERTY(color) + +- (void)set_animating:(NSNumber *)value + forView:(UIActivityIndicatorView *)view + withDefaultView:(UIActivityIndicatorView *)defaultView +{ + BOOL animating = value ? [value boolValue] : [defaultView isAnimating]; + if (animating != [view isAnimating]) { + if (animating) { + [view startAnimating]; + } else { + [view stopAnimating]; + } + } +} + ++ (NSDictionary *)constantsToExport +{ + return + @{ + @"StyleWhite": @(UIActivityIndicatorViewStyleWhite), + @"StyleWhiteLarge": @(UIActivityIndicatorViewStyleWhiteLarge), + @"StyleGray": @(UIActivityIndicatorViewStyleGray), + }; +} + +@end diff --git a/ReactKit/Views/RCTView.h b/ReactKit/Views/RCTView.h new file mode 100644 index 0000000000..77dee1df8c --- /dev/null +++ b/ReactKit/Views/RCTView.h @@ -0,0 +1,24 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTView.h" + +#import + +#import "RCTPointerEvents.h" + +@protocol RCTAutoInsetsProtocol; + +@interface RCTView : UIView + +@property (nonatomic, assign) RCTPointerEvents pointerEvents; +@property (nonatomic, copy) NSString *overrideAccessibilityLabel; + ++ (void)autoAdjustInsetsForView:(UIView *)parentView + withScrollView:(UIScrollView *)scrollView + updateOffset:(BOOL)updateOffset; + ++ (UIViewController *)backingViewControllerForView:(UIView *)view; + ++ (UIEdgeInsets)contentInsetsForView:(UIView *)curView; + +@end diff --git a/ReactKit/Views/RCTView.m b/ReactKit/Views/RCTView.m new file mode 100644 index 0000000000..abe6f00d97 --- /dev/null +++ b/ReactKit/Views/RCTView.m @@ -0,0 +1,135 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTView.h" + +#import "RCTAutoInsetsProtocol.h" +#import "RCTConvert.h" +#import "RCTLog.h" + +static NSString *RCTRecursiveAccessibilityLabel(UIView *view) +{ + NSMutableString *str = [NSMutableString stringWithString:@""]; + for (UIView *subview in view.subviews) { + NSString *label = [subview accessibilityLabel]; + if (label) { + [str appendString:@" "]; + [str appendString:label]; + } else { + [str appendString:RCTRecursiveAccessibilityLabel(subview)]; + } + } + return str; +} + +@implementation RCTView + +- (id)initWithFrame:(CGRect)frame +{ + self = [super initWithFrame:frame]; + if (self) { + _pointerEvents = RCTPointerEventsUnspecified; + } + return self; +} + +- (NSString *)accessibilityLabel +{ + if (self.overrideAccessibilityLabel) { + return self.overrideAccessibilityLabel; + } + return RCTRecursiveAccessibilityLabel(self); +} + +- (void)setPointerEvents:(RCTPointerEvents)pointerEvents +{ + _pointerEvents = pointerEvents; + self.userInteractionEnabled = (pointerEvents != RCTPointerEventsNone); + if (pointerEvents == RCTPointerEventsBoxNone) { + self.accessibilityViewIsModal = NO; // TODO: find out what this is for + } +} + +- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event +{ + switch (_pointerEvents) { + case RCTPointerEventsNone: + return nil; + case RCTPointerEventsUnspecified: + return [super hitTest:point withEvent:event]; + case RCTPointerEventsBoxOnly: + return [super hitTest:point withEvent:event] ? self: nil; + case RCTPointerEventsBoxNone: + for (UIView *subview in [self.subviews reverseObjectEnumerator]) { + if (!subview.isHidden && subview.isUserInteractionEnabled && subview.alpha > 0) { + CGPoint convertedPoint = [subview convertPoint:point fromView:self]; + UIView *subviewHitTestView = [subview hitTest:convertedPoint withEvent:event]; + if (subviewHitTestView != nil) { + return subviewHitTestView; + } + } + } + return nil; + default: + RCTLogError(@"Invalid pointer-events specified %zd on %@", _pointerEvents, self); + return [super hitTest:point withEvent:event]; + } +} + +#pragma mark - Statics for dealing with layoutGuides + ++ (void)autoAdjustInsetsForView:(UIView *)parentView + withScrollView:(UIScrollView *)scrollView + updateOffset:(BOOL)updateOffset +{ + UIEdgeInsets baseInset = parentView.contentInset; + CGFloat previousInsetTop = scrollView.contentInset.top; + CGPoint contentOffset = scrollView.contentOffset; + + if (parentView.automaticallyAdjustContentInsets) { + UIEdgeInsets autoInset = [self contentInsetsForView:parentView]; + baseInset.top += autoInset.top; + baseInset.bottom += autoInset.bottom; + baseInset.left += autoInset.left; + baseInset.right += autoInset.right; + } + [scrollView setContentInset:baseInset]; + [scrollView setScrollIndicatorInsets:baseInset]; + + if (updateOffset) { + // If we're adjusting the top inset, then let's also adjust the contentOffset so that the view + // elements above the top guide do not cover the content. + // This is generally only needed when your views are initially laid out, for + // manual changes to contentOffset, you can optionally disable this step + CGFloat currentInsetTop = scrollView.contentInset.top; + if (currentInsetTop != previousInsetTop) { + contentOffset.y -= (currentInsetTop - previousInsetTop); + scrollView.contentOffset = contentOffset; + } + } +} + ++ (UIViewController *)backingViewControllerForView:(UIView *)view +{ + id responder = [view nextResponder]; + if ([responder isKindOfClass:[UIViewController class]]) { + return responder; + } + return nil; +} + ++ (UIEdgeInsets)contentInsetsForView:(UIView *)view +{ + while (view) { + UIViewController *controller = [self backingViewControllerForView:view]; + if (controller) { + return (UIEdgeInsets){ + controller.topLayoutGuide.length, 0, + controller.bottomLayoutGuide.length, 0 + }; + } + view = view.superview; + } + return UIEdgeInsetsZero; +} + +@end diff --git a/ReactKit/Views/RCTViewManager.h b/ReactKit/Views/RCTViewManager.h new file mode 100644 index 0000000000..e77be09c12 --- /dev/null +++ b/ReactKit/Views/RCTViewManager.h @@ -0,0 +1,148 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import + +#import "RCTConvert.h" +#import "RCTLog.h" + +@class RCTEventDispatcher; +@class RCTShadowView; +@class RCTSparseArray; +@class RCTUIManager; + +typedef void (^RCTViewManagerUIBlock)(RCTUIManager *uiManager, RCTSparseArray *viewRegistry); + +@interface RCTViewManager : NSObject + +/** + * Designated initializer for view modules. Override this when subclassing. + */ +- (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher NS_DESIGNATED_INITIALIZER; + +/** + * The event dispatcher is used to send events back to the JavaScript application. + * It can either be used directly by the module, or passed on to instantiated + * view subclasses so that they can handle their own events. + */ +@property (nonatomic, readonly, weak) RCTEventDispatcher *eventDispatcher; + +/** + * The module name exposed to React JS. If omitted, this will be inferred + * automatically by using the view module's class name. It is better to not + * override this, and just follow standard naming conventions for your view + * module subclasses. + */ ++ (NSString *)moduleName; + +/** + * This method instantiates a native view to be managed by the module. Override + * this to return a custom view instance, which may be preconfigured with default + * properties, subviews, etc. This method will be called many times, and should + * return a fresh instance each time. The view module MUST NOT cache the returned + * view and return the same instance for subsequent calls. + */ +- (UIView *)view; + +/** + * This method instantiates a shadow view to be managed by the module. If omitted, + * an ordinary RCTShadowView instance will be created, which is typically fine for + * most view types. As with the -view method, the -shadowView method should return + * a fresh instance each time it is called. + */ +- (RCTShadowView *)shadowView; + +/** + * Returns a dictionary of config data passed to JS that defines eligible events + * that can be placed on native views. This should return bubbling + * directly-dispatched event types and specify what names should be used to + * subscribe to either form (bubbling/capturing). + * + * Returned dictionary should be of the form: @{ + * @"onTwirl": { + * @"phasedRegistrationNames": @{ + * @"bubbled": @"onTwirl", + * @"captured": @"onTwirlCaptured" + * } + * } + * } + * + * Note that this method is not inherited when you subclass a view module, and + * you should not call [super customBubblingEventTypes] when overriding it. + */ ++ (NSDictionary *)customBubblingEventTypes; + +/** + * Returns a dictionary of config data passed to JS that defines eligible events + * that can be placed on native views. This should return non-bubbling + * directly-dispatched event types. + * + * Returned dictionary should be of the form: @{ + * @"onTwirl": { + * @"registrationName": @"onTwirl" + * } + * } + * + * Note that this method is not inherited when you subclass a view module, and + * you should not call [super customDirectEventTypes] when overriding it. + */ ++ (NSDictionary *)customDirectEventTypes; + +/** + * Injects constants into JS. These constants are made accessible via + * NativeModules.moduleName.X. Note that this method is not inherited when you + * subclass a view module, and you should not call [super constantsToExport] + * when overriding it. + */ ++ (NSDictionary *)constantsToExport; + +/** + * To deprecate, hopefully + */ +- (RCTViewManagerUIBlock)uiBlockToAmendWithShadowViewRegistry:(RCTSparseArray *)shadowViewRegistry; + +/** + * Informal protocol for setting view and shadowView properties. + * Implement methods matching these patterns to set any properties that + * require special treatment (e.g. where the type or name cannot be inferred). + * + * - (void)set_:(id)property + * forView:(UIView *)view + * withDefaultView:(UIView *)defaultView; + * + * - (void)set_:(id)property + * forShadowView:(RCTShadowView *)view + * withDefaultView:(RCTShadowView *)defaultView; + * + * For simple cases, use the macros below: + */ + +/** + * This handles the simple case, where JS and native property names match + * And the type can be automatically inferred. + */ +#define RCT_EXPORT_VIEW_PROPERTY(name) \ +RCT_REMAP_VIEW_PROPERTY(name, name) + +/** + * This macro maps a named property on the module to an arbitrary key path + * within the view. + */ +#define RCT_REMAP_VIEW_PROPERTY(name, keypath) \ +- (void)set_##name:(id)json forView:(id)view withDefaultView:(id)defaultView { \ + if ((json && !RCTSetProperty(view, @#keypath, json)) || \ + (!json && !RCTCopyProperty(view, defaultView, @#keypath))) { \ + RCTLogMustFix(@"%@ does not have setter for `%s` property", [view class], #name); \ + } \ +} + +/** + * These are useful in cases where the module's superclass handles a + * property, but you wish to "unhandle" it, so it will be ignored. + */ +#define RCT_IGNORE_VIEW_PROPERTY(name) \ +- (void)set_##name:(id)value forView:(id)view withDefaultView:(id)defaultView {} + +#define RCT_IGNORE_SHADOW_PROPERTY(name) \ +- (void)set_##name:(id)value forShadowView:(id)view withDefaultView:(id)defaultView {} + +@end diff --git a/ReactKit/Views/RCTViewManager.m b/ReactKit/Views/RCTViewManager.m new file mode 100644 index 0000000000..05ecc2198f --- /dev/null +++ b/ReactKit/Views/RCTViewManager.m @@ -0,0 +1,170 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTViewManager.h" + +#import "RCTConvert.h" +#import "RCTEventDispatcher.h" +#import "RCTLog.h" +#import "RCTShadowView.h" +#import "RCTView.h" + +@implementation RCTViewManager + +- (instancetype)initWithEventDispatcher:(RCTEventDispatcher *)eventDispatcher +{ + if ((self = [super init])) { + _eventDispatcher = eventDispatcher; + } + return self; +} + ++ (NSString *)moduleName +{ + // Default implementation, works in most cases + NSString *name = NSStringFromClass(self); + if ([name hasPrefix:@"RCTUI"]) { + name = [name substringFromIndex:@"RCT".length]; + } + if ([name hasSuffix:@"Manager"]) { + name = [name substringToIndex:name.length - @"Manager".length]; + } + return name; +} + +- (UIView *)view +{ + return [[RCTView alloc] init]; +} + +- (RCTShadowView *)shadowView +{ + return [[RCTShadowView alloc] init]; +} + ++ (NSDictionary *)customBubblingEventTypes +{ + return nil; +} + ++ (NSDictionary *)customDirectEventTypes +{ + return nil; +} + ++ (NSDictionary *)constantsToExport +{ + return nil; +} + +- (RCTViewManagerUIBlock)uiBlockToAmendWithShadowViewRegistry:(RCTSparseArray *)shadowViewRegistry +{ + return nil; +} + +// View properties + +RCT_EXPORT_VIEW_PROPERTY(accessibilityLabel) +RCT_EXPORT_VIEW_PROPERTY(hidden) +RCT_EXPORT_VIEW_PROPERTY(backgroundColor) +RCT_REMAP_VIEW_PROPERTY(accessible, isAccessibilityElement) +RCT_REMAP_VIEW_PROPERTY(testID, accessibilityIdentifier) +RCT_REMAP_VIEW_PROPERTY(opacity, alpha) +RCT_REMAP_VIEW_PROPERTY(shadowColor, layer.shadowColor); +RCT_REMAP_VIEW_PROPERTY(shadowOffset, layer.shadowOffset); +RCT_REMAP_VIEW_PROPERTY(shadowOpacity, layer.shadowOpacity) +RCT_REMAP_VIEW_PROPERTY(shadowRadius, layer.shadowRadius) +RCT_REMAP_VIEW_PROPERTY(borderColor, layer.borderColor); +RCT_REMAP_VIEW_PROPERTY(borderRadius, layer.cornerRadius) +RCT_REMAP_VIEW_PROPERTY(borderWidth, layer.borderWidth) +RCT_REMAP_VIEW_PROPERTY(transformMatrix, layer.transform) + +- (void)set_overflow:(id)json + forView:(UIView *)view + withDefaultView:(UIView *)defaultView +{ + view.clipsToBounds = json ? ![RCTConvert css_overflow:json] : defaultView.clipsToBounds; +} + +- (void)set_pointerEvents:(id)json + forView:(RCTView *)view + withDefaultView:(RCTView *)defaultView +{ + if ([view respondsToSelector:@selector(setPointerEvents:)]) { + view.pointerEvents = json ? [RCTConvert RCTPointerEvents:json] : defaultView.pointerEvents; + return; + } + + if (!json) { + view.userInteractionEnabled = defaultView.userInteractionEnabled; + return; + } + + switch ([RCTConvert NSInteger:json]) { + case RCTPointerEventsUnspecified: + // Pointer events "unspecified" acts as if a stylesheet had not specified, + // which is different than "auto" in CSS (which cannot and will not be + // supported in `ReactKit`. "auto" may override a parent's "none". + // Unspecified values do not. + // This wouldn't override a container view's `userInteractionEnabled = NO` + view.userInteractionEnabled = YES; + case RCTPointerEventsNone: + view.userInteractionEnabled = NO; + break; + default: + RCTLogError(@"UIView base class does not support pointerEvent value: %@", json); + } +} + +// ShadowView properties + +- (void)set_backgroundColor:(id)json + forShadowView:(RCTShadowView *)shadowView + withDefaultView:(RCTShadowView *)defaultView +{ + shadowView.backgroundColor = json ? [RCTConvert UIColor:json] : defaultView.backgroundColor; + shadowView.isBGColorExplicitlySet = json ? YES : defaultView.isBGColorExplicitlySet; +} + +- (void)set_flexDirection:(id)json + forShadowView:(RCTShadowView *)shadowView + withDefaultView:(RCTShadowView *)defaultView +{ + shadowView.flexDirection = json? [RCTConvert css_flex_direction_t:json] : defaultView.flexDirection; +} + +- (void)set_flexWrap:(id)json + forShadowView:(RCTShadowView *)shadowView + withDefaultView:(RCTShadowView *)defaultView +{ + shadowView.flexWrap = json ? [RCTConvert css_wrap_type_t:json] : defaultView.flexWrap; +} + +- (void)set_justifyContent:(id)json + forShadowView:(RCTShadowView *)shadowView + withDefaultView:(RCTShadowView *)defaultView +{ + shadowView.justifyContent = json ? [RCTConvert css_justify_t:json] : defaultView.justifyContent; +} + +- (void)set_alignItems:(id)json + forShadowView:(RCTShadowView *)shadowView + withDefaultView:(RCTShadowView *)defaultView +{ + shadowView.alignItems = json ? [RCTConvert css_align_t:json] : defaultView.alignItems; +} + +- (void)set_alignSelf:(id)json + forShadowView:(RCTShadowView *)shadowView + withDefaultView:(RCTShadowView *)defaultView +{ + shadowView.alignSelf = json ? [RCTConvert css_align_t:json] : defaultView.alignSelf; +} + +- (void)set_position:(id)json + forShadowView:(RCTShadowView *)shadowView + withDefaultView:(RCTShadowView *)defaultView +{ + shadowView.positionType = json ? [RCTConvert css_position_type_t:json] : defaultView.positionType; +} + +@end diff --git a/ReactKit/Views/RCTWrapperViewController.h b/ReactKit/Views/RCTWrapperViewController.h new file mode 100644 index 0000000000..d8f22270a2 --- /dev/null +++ b/ReactKit/Views/RCTWrapperViewController.h @@ -0,0 +1,24 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import + +@class RCTEventDispatcher; +@class RCTNavItem; +@class RCTWrapperViewController; + +@protocol RCTWrapperViewControllerNavigationListener + +- (void)wrapperViewController:(RCTWrapperViewController *)wrapperViewController +didMoveToNavigationController:(UINavigationController *)navigationController; + +@end + +@interface RCTWrapperViewController : UIViewController + +- (instancetype)initWithContentView:(UIView *)contentView eventDispatcher:(RCTEventDispatcher *)eventDispatcher; +- (instancetype)initWithNavItem:(RCTNavItem *)navItem eventDispatcher:(RCTEventDispatcher *)eventDispatcher; + +@property (nonatomic, readwrite, weak) id navigationListener; +@property (nonatomic, strong, readwrite) RCTNavItem *navItem; + +@end diff --git a/ReactKit/Views/RCTWrapperViewController.m b/ReactKit/Views/RCTWrapperViewController.m new file mode 100644 index 0000000000..d027dc2f29 --- /dev/null +++ b/ReactKit/Views/RCTWrapperViewController.m @@ -0,0 +1,113 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "RCTWrapperViewController.h" + +#import "RCTEventDispatcher.h" +#import "RCTNavItem.h" +#import "RCTUtils.h" +#import "UIView+ReactKit.h" + +@implementation RCTWrapperViewController +{ + UIView *_contentView; + RCTEventDispatcher *_eventDispatcher; + CGFloat _previousTopLayout; + CGFloat _previousBottomLayout; +} + +- (id)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil +{ + RCT_NOT_DESIGNATED_INITIALIZER(); +} + +- (instancetype)initWithContentView:(UIView *)contentView eventDispatcher:(RCTEventDispatcher *)eventDispatcher +{ + if ((self = [super initWithNibName:nil bundle:nil])) { + _contentView = contentView; + _eventDispatcher = eventDispatcher; + self.automaticallyAdjustsScrollViewInsets = NO; + } + return self; +} + +- (instancetype)initWithNavItem:(RCTNavItem *)navItem eventDispatcher:(RCTEventDispatcher *)eventDispatcher +{ + if ((self = [self initWithContentView:navItem eventDispatcher:eventDispatcher])) { + _navItem = navItem; + } + return self; +} + +- (void)viewWillAppear:(BOOL)animated +{ + [super viewWillAppear:animated]; + + [self.navigationController setNavigationBarHidden:!_navItem animated:animated]; + if (!_navItem) { + return; + } + + self.navigationItem.title = _navItem.title; + + [self _configureNavBarStyle]; + + if (_navItem.rightButtonTitle.length > 0) { + self.navigationItem.rightBarButtonItem = + [[UIBarButtonItem alloc] initWithTitle:_navItem.rightButtonTitle + style:UIBarButtonItemStyleDone + target:self + action:@selector(rightButtonTapped)]; + } + + if (_navItem.backButtonTitle.length > 0) { + self.navigationItem.backBarButtonItem = + [[UIBarButtonItem alloc] initWithTitle:_navItem.backButtonTitle + style:UIBarButtonItemStylePlain + target:nil + action:nil]; + } +} + +- (void)_configureNavBarStyle +{ + UINavigationBar *bar = self.navigationController.navigationBar; + if (_navItem.barTintColor) { + bar.barTintColor = _navItem.barTintColor; + } + if (_navItem.tintColor) { + BOOL canSetTintColor = _navItem.barTintColor == nil; + if (canSetTintColor) { + bar.tintColor = _navItem.tintColor; + } + } + if (_navItem.titleTextColor) { + [bar setTitleTextAttributes:@{NSForegroundColorAttributeName : _navItem.titleTextColor}]; + } +} + +- (void)loadView +{ + // Add a wrapper so that UIViewControllerWrapperView (managed by the + // UINavigationController) doesn't end up resetting the frames for + // `contentView` which is a react-managed view. + self.view = [[UIView alloc] init]; + [self.view addSubview:_contentView]; +} + +- (void)rightButtonTapped +{ + [_eventDispatcher sendInputEventWithName:@"topNavRightButtonTap" body:@{@"target":_navItem.reactTag}]; +} + +- (void)didMoveToParentViewController:(UIViewController *)parent +{ + // There's no clear setter for navigation controllers, but did move to parent view controller + // provides the desired effect. This is called after a pop finishes, be it a swipe to go back + // or a standard tap on the back button + [super didMoveToParentViewController:parent]; + if (parent == nil || [parent isKindOfClass:[UINavigationController class]]) { + [self.navigationListener wrapperViewController:self didMoveToNavigationController:(UINavigationController *)parent]; + } +} + +@end diff --git a/ReactKit/Views/UIView+ReactKit.h b/ReactKit/Views/UIView+ReactKit.h new file mode 100644 index 0000000000..3e45da8e7b --- /dev/null +++ b/ReactKit/Views/UIView+ReactKit.h @@ -0,0 +1,11 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import + +#import "RCTViewNodeProtocol.h" + +//TODO: let's try to eliminate this category if possible + +@interface UIView (ReactKit) + +@end diff --git a/ReactKit/Views/UIView+ReactKit.m b/ReactKit/Views/UIView+ReactKit.m new file mode 100644 index 0000000000..39bca8ec67 --- /dev/null +++ b/ReactKit/Views/UIView+ReactKit.m @@ -0,0 +1,51 @@ +// Copyright 2004-present Facebook. All Rights Reserved. + +#import "UIView+ReactKit.h" + +#import + +#import "RCTAssert.h" + +@implementation UIView (ReactKit) + +- (NSNumber *)reactTag +{ + return objc_getAssociatedObject(self, _cmd); +} + +- (void)setReactTag:(NSNumber *)reactTag +{ + objc_setAssociatedObject(self, @selector(reactTag), reactTag, OBJC_ASSOCIATION_COPY_NONATOMIC); +} + +- (BOOL)isReactRootView +{ + return NO; +} + +- (NSNumber *)reactTagAtPoint:(CGPoint)point +{ + UIView *view = [self hitTest:point withEvent:nil]; + while (view && !view.reactTag) { + view = view.superview; + } + return view.reactTag; +} + +- (void)insertReactSubview:(UIView *)subview atIndex:(NSInteger)atIndex +{ + [self insertSubview:subview atIndex:atIndex]; +} + +- (void)removeReactSubview:(UIView *)subview +{ + RCTAssert(subview.superview == self, @""); + [subview removeFromSuperview]; +} + +- (NSArray *)reactSubviews +{ + return self.subviews; +} + +@end diff --git a/docs/GettingStarted.md b/docs/GettingStarted.md new file mode 100644 index 0000000000..eda46461b5 --- /dev/null +++ b/docs/GettingStarted.md @@ -0,0 +1,79 @@ +--- +id: getting-started +title: Getting Started +layout: docs +category: Quick Start +permalink: docs/getting-started.html +next: navigatorios +--- + + +Our first React Native implementation is `ReactKit`, targeting iOS. We are also +working on an Android implementation which we will release later. `ReactKit` +apps are built using the [React JS](https://github.com/facebook/react) framework, and render directly to +native UIKit elements using a fully asynchronous architecture. There is no +browser and no HTML. We have picked what we think is the best set of features +from these and other technologies to build what we hope to become the best +product development framework available, with an emphasis on iteration speed, +developer delight, continuity of technology, and absolutely beautiful and fast +products with no compromises in quality or capability. + +## Requirements + +1. OS X - This repo only contains the iOS implementation right now, and Xcode only runs on Mac. +2. New to Xcode? [Download it](https://developer.apple.com/xcode/downloads/) from the Mac App Store. +3. [Homebrew](http://brew.sh/) is the recommended way to install node, watchman, and flow. +4. New to node or npm? `brew install node` +5. We recommend installing [watchman](https://facebook.github.io/watchman/docs/install.html), otherwise you might hit a node file watching bug. `brew install watchman` +6. If you want to use [flow](http://www.flowtype.org), `brew install flow` + +## Quick start + +Get up and running with our Movies sample app: + +1. Once you have the repo cloned and met all the requirements above, start the +packager that will transform your JS code on-the-fly: + + ``` + npm install + npm start + ``` +2. Open the `Examples/Movies/Movies.xcodeproj` project in Xcode. +3. Make sure the target is set to `Movies` and that you have an iOS simulator +selected to run the app. +4. Build and run the project with the Xcode run button. + +You should now see the Movies app running on your iOS simulator. +Congratulations! You've just successfully run your first React Native app. + +Now try editing a JavaScript file and viewing your changes. Let's change the +movie search placeholder text: + +1. Open the `Examples/Movies/SearchScreen.js` file in your favorite JavaScript +editor. +2. Look for the current search placeholder text and change it to "Search for an +awesome movie...". +3. Hit cmd+R ([twice](http://openradar.appspot.com/19613391)) in your iOS simulator to reload the app and see your change. +If you don't immediately see your changes, try restarting your app within Xcode. + +Feel free to browse the Movies sample files and customize various properties to +get familiar with the codebase and React Native. + +Also check out the UI Component Explorer for more sample code: +`Examples/UIExplorer/UIExplorer.xcodeproj`. **Make sure to close the Movies +project first - Xcode will break if you have two projects open that reference +the same library.** + +## Troubleshooting + ++ Xcode will break if you have two examples open at the same time. ++ If `npm start` fails with log spew like: + ``` + 2015-02-02 10:56 node[24294] (FSEvents.framework) FSEventStreamStart: register_with_server: ERROR: f2d_register_rpc() => (null) (-21) + ``` +then you've hit the node file watching bug - `brew install watchman` should fix the issue. ++ Jest testing does not yet work on node versions after 0.10.x. ++ You can verify the packager is working by loading the [bundle](http://localhost:8081/Examples/Movies/MoviesApp.includeRequire.runModule.bundle) in your browser and +inspecting the contents. + +Please report any other issues you encounter so we can fix them ASAP. diff --git a/docs/Image.md b/docs/Image.md new file mode 100644 index 0000000000..0b1ccf2022 --- /dev/null +++ b/docs/Image.md @@ -0,0 +1,49 @@ +Displaying images is a fascinating subject, React Native uses some cool tricks to make it a better experience. + +## No Automatic Sizing + +If you don't give a size to an image, the browser is going to render a 0x0 element, download the image, and then render the image based with the correct size. The big issue with this behavior is that your UI is going to jump all around as images load, this makes for a very bad user experience. + +In React Native, this behavior is intentionally not implemented. It is more work for the developer to know the dimensions (or just aspect ratio) of the image in advance, but we believe that it leads to a better user experience. + +## Background Image via Nesting + +A common feature request from developers familiar with the web is `background-image`. It turns out that iOS has a very elegant solution to this: you can add elements as a children to an `` component. This simplifies the API and solves the use case. + +```javascript +return ( + + Inside + +); +``` + +## Off-thread Decoding + +Image decoding can take more than a frame-worth of time. This is one of the major source of frame drops on the web because decoding is done in the main thread. In React Native, image decoding is done in a different thread. In practice, you already need to handle the case when the image is not downloaded yet, so displaying the placeholder for a few more frames while it is decoding does not require any code change. + +## Static Assets + +In the course of a project you add and remove images and in many instances, you end up shipping images you are not using anymore in the app. In order to fight this, we need to find a way to know statically which images are being used in the app. To do that, we introduced a marker called `ix`. The only allowed way to refer to an image in the bundle is to literally write `ix('name-of-the-asset')` in the source. + +```javascript +var { ix } = React; + +// GOOD + + +// BAD +var icon = this.props.active ? 'my-icon-active' : 'my-icon-inactive'; + + +// GOOD +var icon = this.props.active ? ix('my-icon-active') : ix('my-icon-inactive'); + +``` + +When your entire codebase respects this convention, you're able to do interesting things like automatically packaging the assets that are being used in your app. Note that in the current form, nothing is enforced, but it will be in the future. + +## Best Camera Roll Image + +iOS saves multiple sizes for the same image in your Camera Roll, it is very important to pick the one that's as close as possible for performance reasons. You wouldn't want to use the full quality 3264x2448 image as source when displaying a 200x200 thumbnail. If there's an exact match, React Native will pick it, otherwise it's going to use the first one that's at least 50% bigger in order to avoid blur when resizing from a close size. All of this is done by default so you don't have to worry about writing the tedious (and error prone) code to do it yourself. + diff --git a/docs/Network.md b/docs/Network.md new file mode 100644 index 0000000000..831adccd2e --- /dev/null +++ b/docs/Network.md @@ -0,0 +1,42 @@ +One of React Native goal is to be a playground where we can experiment with different architectures and crazy ideas. Since browsers are not flexible enough, we had no choice but to reimplement the entire stack. In the places that we did not intend to change, we tried to be as faithful as possible to the browser APIs, the networking stack is a great example. + +## XMLHttpRequest + +XMLHttpRequest API is implemented on-top of [iOS networking apis](https://developer.apple.com/library/mac/documentation/Cocoa/Conceptual/URLLoadingSystem/URLLoadingSystem.html). The notable difference from web is the security model: you can read from arbitrary websites on the internet, there isn't no concept of [CORS](http://en.wikipedia.org/wiki/Cross-origin_resource_sharing). + +```javascript +var request = new XMLHttpRequest(); +request.onreadystatechange = (e) => { + if (request.readyState !== 4) { + return; + } + + if (request.status === 200) { + console.log('success', request.responseText); + } else { + console.warn('error'); + } +}; + +request.open('GET', 'https://mywebsite.com/endpoint.php'); +request.send(); +``` + +Please follow the [MDN Documentation](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest) for a description of the API. + +As a developer, you're probably not going to use XMLHttpRequest directly as its API is very tedious to work with. But the fact that it is implemented and compatible with the browser one gives you the ability to use third-party libraries such as [Parse JS SDK](https://parse.com/docs/js_guide) or [super-agent](https://github.com/visionmedia/superagent) directly from npm. + +## Fetch + +[fetch](https://fetch.spec.whatwg.org/) is a better API being worked on by the standard committee and already available in Chrome. It is available in React Native by default. + +```javascript +fetch('https://mywebsite.com/endpoint.php') + .then((response) => response.text()) + .then((responseText) => { + console.log(responseText); + }) + .catch((error) => { + console.warn(error); + }); +``` diff --git a/docs/Timers.md b/docs/Timers.md new file mode 100644 index 0000000000..81f4222136 --- /dev/null +++ b/docs/Timers.md @@ -0,0 +1,34 @@ +Timers are an important part of an application and React Native implements the [browser timers](https://developer.mozilla.org/en-US/Add-ons/Code_snippets/Timers). + +## Timers + +- setTimeout, clearTimeout +- setInterval, clearInterval +- setImmediate, clearImmediate +- requestAnimationFrame, cancelAnimationFrame + +`requestAnimationFrame(fn)` is the exact equivalent of `setTimeout(fn, 0)`, they are triggered right after the screen has been flushed. + +`setImmediate` is executed at the end of the current JavaScript execution block, right before sending the batched response back to native. Note that if you call `setImmediate` within a `setImmediate` callback, it will be executed right away, it won't yield back to native in between. + +The `Promise` implementation uses `setImmediate` its asynchronicity primitive. + +## TimerMixin + +We found out that the primary cause of fatals in apps created with React Native was due to timers firing after a component was unmounted. To solve this recurring issue, we introduced `TimerMixin`. If you include `TimerMixin`, then you can replace your calls to `setTimeout(fn, 500)` with `this.setTimeout(fn, 500)` (just prepend `this.`) and everything will be properly cleaned up for you when the component unmounts. + +```javascript +var { TimerMixin } = React; + +var Component = React.createClass({ + mixins: [TimerMixin], + componentDidMount: function() { + this.setTimeout( + () => { console.log('I do not leak!'); }, + 500 + ); + } +}); +``` + +We highly recommend never using bare timers and always using this mixin, it will save you from a lot of hard to track down bugs. diff --git a/jestSupport/scriptPreprocess.js b/jestSupport/scriptPreprocess.js new file mode 100644 index 0000000000..9231196c86 --- /dev/null +++ b/jestSupport/scriptPreprocess.js @@ -0,0 +1,21 @@ +'use strict'; + +var transformer = require('../packager/transformer.js'); + +function transformSource(src) { + return transformer.transform(null, src).code; +} + +module.exports = { + transformSource: transformSource, + + process: function(src, fileName) { + try { + return transformSource(src); + } catch(e) { + throw new Error('\nError transforming file:\n js/' + + (fileName.split('/js/')[1] || fileName) + ':' + e.lineNumber + ': \'' + + e.message + '\'\n'); + } + } +}; diff --git a/linter.js b/linter.js new file mode 100644 index 0000000000..ec7aa7c7ab --- /dev/null +++ b/linter.js @@ -0,0 +1,83 @@ +// Copyright 2012-present Facebook. All Rights Reserved. +'use strict'; + +var eslint = require('eslint'); +var transformSource = require('./jestSupport/scriptPreprocess.js').transformSource; + +var ignoredStylisticRules = { + 'key-spacing': false, + 'comma-spacing': true, + 'no-multi-spaces': true, + 'brace-style': true, + 'camelcase': true, + 'consistent-this': true, + 'eol-last': true, + 'func-names': true, + 'func-style': true, + 'new-cap': true, + 'new-parens': true, + 'no-nested-ternary': true, + 'no-array-constructor': true, + 'no-lonely-if': true, + 'no-new-object': true, + 'no-spaced-func': true, + 'no-space-before-semi': true, + 'no-ternary': true, + 'no-trailing-spaces': true, + 'no-underscore-dangle': true, + 'no-wrap-func': true, + 'no-mixed-spaces-and-tabs': true, + 'quotes': true, + 'quote-props': true, + 'semi': true, + 'sort-vars': true, + 'space-after-keywords': true, + 'space-in-brackets': true, + 'space-in-parens': true, + 'space-infix-ops': true, + 'space-return-throw-case': true, + 'space-unary-word-ops': true, + 'max-nested-callbacks': true, + 'one-var': true, + 'wrap-regex': true, + 'curly': true, + 'no-mixed-requires': true, +}; + +/* + * Currently ESLint does not understand ES6+React-flavoured syntax. + * To make it work on our codebase, we monkey-patch `verify` function + * to do a transform before running lint rules. + * + * If future, as ESLint's support for ES6 expands, we can get rid of this + * hack + */ +var originalVerify = eslint.linter.verify; +eslint.linter.verify = function(text, config, filename, saveState) { + var transformedText; + try { + transformedText = transformSource(text); + } catch (e) { + return [{ + severity: 2, + line: e.lineNumber, + message: e.message, + source: text + }]; + } + var originalLines = text.split('\n'); + var transformedLines = transformedText.split('\n'); + var warnings = originalVerify.call(eslint.linter, transformedText, config, filename, saveState); + + // JSX and ES6 transforms usually generate pretty ugly code. Let's skip lint warnings + // about code style for lines that have been changed by transform step. + // Note that more important issues, like use of undefined vars, will still be reported. + return warnings.filter(function(error) { + var lineHasBeenTransformed = originalLines[error.line - 1] !== transformedLines[error.line - 1]; + var shouldIgnore = ignoredStylisticRules[error.ruleId] && lineHasBeenTransformed; + return !shouldIgnore; + }); +}; + +// Run the original CLI +require('eslint/bin/eslint'); diff --git a/package.json b/package.json new file mode 100644 index 0000000000..c377c5d9ee --- /dev/null +++ b/package.json @@ -0,0 +1,54 @@ +{ + "name": "react-native", + "version": "0.0.1", + "description": "Build native apps with React!", + "repository": { + "type": "git", + "url": "git@github.com:facebook/react-native.git" + }, + "jest": { + "scriptPreprocessor": "jestSupport/scriptPreprocess.js", + "testPathIgnorePatterns": [ + "/node_modules/", + "/packager/" + ], + "testFileExtensions": ["js"] + }, + "scripts": { + "test": "jest", + "lint": "node linter.js Examples/", + "start": "./packager/packager.sh" + }, + "dependencies": { + "absolute-path": "0.0.0", + "base62": "0.1.1", + "connect": "2.8.3", + "debug": "~2.1.0", + "esprima-fb": "7001.0001.0000-dev-harmony-fb", + "fs-extra": "0.15.0", + "jstransform": "8.2.0", + "mime": "1.2.11", + "module-deps": "3.5.6", + "node-haste": "1.2.6", + "node-static": "0.7.6", + "optimist": "0.6.1", + "path-is-inside": "1.0.1", + "punycode": "1.2.4", + "q": "1.0.1", + "qs": "0.6.5", + "react-tools": "0.12.2", + "rebound": "0.0.10", + "sane": "1.0.1", + "source-map": "0.1.31", + "stacktrace-parser": "0.1.1", + "through": "2.3.6", + "underscore": "1.7.0", + "wordwrap": "0.0.2", + "worker-farm": "1.1.0", + "yargs": "1.3.2" + }, + "devDependencies": { + "jest-cli": "0.2.1", + "eslint": "0.9.2" + } +} diff --git a/packager/blacklist.js b/packager/blacklist.js new file mode 100644 index 0000000000..2b710af628 --- /dev/null +++ b/packager/blacklist.js @@ -0,0 +1,45 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + */ +'use strict'; + +// Don't forget to everything listed here to `testConfig.json` +// modulePathIgnorePatterns. +var sharedBlacklist = [ + 'node_modules/JSAppServer', + 'packager/react-packager', + 'node_modules/parse/node_modules/xmlhttprequest/lib/XMLHttpRequest.js', + 'node_modules/react-tools/src/utils/ImmutableObject.js', + 'node_modules/react-tools/src/core/ReactInstanceHandles.js', + 'node_modules/react-tools/src/event/EventPropagators.js', + 'node_modules/jest-cli', +]; + +var webBlacklist = [ + '.ios.js' +]; + +var iosBlacklist = [ + 'node_modules/react-tools/src/browser/ui/React.js', + 'node_modules/react-tools/src/browser/eventPlugins/ResponderEventPlugin.js', + 'node_modules/react-tools/src/browser/ReactTextComponent.js', + // 'node_modules/react-tools/src/vendor/core/ExecutionEnvironment.js', + '.web.js', + '.android.js', +]; + +function escapeRegExp(str) { + return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, '\\$&'); +} + +function blacklist(isWeb) { + return new RegExp('(' + + sharedBlacklist + .concat(isWeb ? webBlacklist : iosBlacklist) + .map(escapeRegExp) + .join('|') + + ')$' + ); +} + +module.exports = blacklist; diff --git a/packager/launchEditor.js b/packager/launchEditor.js new file mode 100644 index 0000000000..93db9bfce4 --- /dev/null +++ b/packager/launchEditor.js @@ -0,0 +1,40 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + */ +'use strict'; + +var fs = require('fs'); +var spawn = require('child_process').spawn; + +var firstLaunch = true; + +function guessEditor() { + if (firstLaunch) { + console.log('When you see Red Box with stack trace, you can click any ' + + 'stack frame to jump to the source file. The packager will launch your ' + + 'editor of choice. It will first look at REACT_EDITOR environment ' + + 'variable, then at EDITOR. To set it up, you can add something like ' + + 'REACT_EDITOR=atom to your .bashrc.'); + firstLaunch = false; + } + + var editor = process.env.REACT_EDITOR || process.env.EDITOR || 'subl'; + return editor; +} + +function launchEditor(fileName, lineNumber) { + if (!fs.existsSync(fileName)) { + return; + } + + var argument = fileName; + if (lineNumber) { + argument += ':' + lineNumber; + } + + var editor = guessEditor(); + console.log('Opening ' + fileName + ' with ' + editor); + spawn(editor, [argument], { stdio: ['pipe', 'pipe', process.stderr] }); +} + +module.exports = launchEditor; diff --git a/packager/launchPackager.command b/packager/launchPackager.command new file mode 100755 index 0000000000..dc56d7ff37 --- /dev/null +++ b/packager/launchPackager.command @@ -0,0 +1,10 @@ +#!/bin/bash + +# Set terminal title +echo -en "\033]0;React Packager\a" +clear + +THIS_DIR=$(dirname "$0") +$THIS_DIR/packager.sh +echo "Process terminated. Press to close the window" +read diff --git a/packager/packager.js b/packager/packager.js new file mode 100644 index 0000000000..6d5336ef47 --- /dev/null +++ b/packager/packager.js @@ -0,0 +1,122 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + */ +'use strict'; + +var fs = require('fs'); +var path = require('path'); + +if (!fs.existsSync(path.resolve(__dirname, '..', 'node_modules'))) { + console.log( + '\n' + + 'Could not find dependencies.\n' + + 'Ensure dependencies are installed - ' + + 'run \'npm install\' from project root.\n' + ); + process.exit(); +} + +var ReactPackager = require('./react-packager'); +var blacklist = require('./blacklist.js'); +var connect = require('connect'); +var http = require('http'); +var launchEditor = require('./launchEditor.js'); +var parseCommandLine = require('./parseCommandLine.js'); + +var options = parseCommandLine([{ + command: 'port', + default: 8081, +}, { + command: 'root', + description: 'add another root(s) to be used by the packager in this project', +}]); + +if (!options.projectRoots) { + options.projectRoots = [path.resolve(__dirname, '..')]; +} + +if (options.root) { + if (typeof options.root === 'string') { + options.projectRoots.push(path.resolve(options.root)); + } else { + options.root.forEach(function(root) { + options.projectRoots.push(path.resolve(root)); + }); + } +} + +console.log('\n' + +' ===============================================================\n' + +' | Running packager on port ' + options.port + '. \n' + +' | Keep this packager running while developing on any JS \n' + +' | projects. Feel free to close this tab and run your own \n' + +' | packager instance if you prefer. \n' + +' | \n' + +' | https://github.com/facebook/react-native \n' + +' | \n' + +' ===============================================================\n' +); + +process.on('uncaughtException', function(e) { + console.error(e); + console.error(e.stack); + console.error('\n >>> ERROR: could not create packager - please shut down ' + + 'any existing instances that are already running.\n\n'); +}); + +runServer(options, function() { + console.log('\nReact packager ready.\n'); +}); + +function loadRawBody(req, res, next) { + req.rawBody = ''; + req.setEncoding('utf8'); + + req.on('data', function(chunk) { + req.rawBody += chunk; + }); + + req.on('end', function() { + next(); + }); +} + +function openStackFrameInEditor(req, res, next) { + if (req.url === '/open-stack-frame') { + var frame = JSON.parse(req.rawBody); + launchEditor(frame.file, frame.lineNumber); + res.end('OK'); + } else { + next(); + } +} + +function getAppMiddleware(options) { + return ReactPackager.middleware({ + dev: true, + projectRoots: options.projectRoots, + blacklistRE: blacklist(false), + cacheVersion: '2', + transformModulePath: require.resolve('./transformer.js'), + }); +} + +function runServer( + options, /* {string projectRoot, bool web, bool dev} */ + readyCallback +) { + var app = connect() + .use(loadRawBody) + .use(openStackFrameInEditor) + .use(getAppMiddleware(options)); + + options.projectRoots.forEach(function(root) { + app.use(connect.static(root)); + }); + + app.use(connect.logger()) + .use(connect.compress()) + .use(connect.errorHandler()); + + return http.createServer(app).listen(options.port, readyCallback); +} diff --git a/packager/packager.sh b/packager/packager.sh new file mode 100755 index 0000000000..98e4218423 --- /dev/null +++ b/packager/packager.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +ulimit -n 4096 + +THIS_DIR=$(dirname "$0") +node $THIS_DIR/packager.js "$@" diff --git a/packager/parseCommandLine.js b/packager/parseCommandLine.js new file mode 100644 index 0000000000..5240d37d67 --- /dev/null +++ b/packager/parseCommandLine.js @@ -0,0 +1,52 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * Wrapper on-top of `optimist` in order to properly support boolean flags + * and have a slightly less akward API. + * + * Usage example: + * var argv = parseCommandLine([{ + * command: 'web', + * description: 'Run in a web browser instead of iOS', + * default: true + * }]) + */ +'use strict'; + +var optimist = require('optimist'); + +function parseCommandLine(config) { + // optimist default API requires you to write the command name three time + // This is a small wrapper to accept an object instead + for (var i = 0; i < config.length; ++i) { + optimist + .boolean(config[i].command) + .default(config[i].command, config[i].default) + .describe(config[i].command, config[i].description); + } + var argv = optimist.argv; + + // optimist doesn't have support for --dev=false, instead it returns 'false' + for (var i = 0; i < config.length; ++i) { + var command = config[i].command; + if (argv[command] === undefined) { + argv[command] = config[i].default; + } + if (argv[command] === 'true') { + argv[command] = true; + } + if (argv[command] === 'false') { + argv[command] = false; + } + } + + // Show --help + if (argv.help || argv.h) { + optimist.showHelp(); + process.exit(); + } + + return argv; +} + +module.exports = parseCommandLine; diff --git a/packager/react-packager/, b/packager/react-packager/, new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packager/react-packager/.jshintrc b/packager/react-packager/.jshintrc new file mode 100644 index 0000000000..7a3f79a72c --- /dev/null +++ b/packager/react-packager/.jshintrc @@ -0,0 +1,86 @@ +{ + "-W093": true, + "asi": false, + "bitwise": true, + "boss": false, + "browser": false, + "camelcase": true, + "couch": false, + "curly": true, + "debug": false, + "devel": true, + "dojo": false, + "eqeqeq": true, + "eqnull": true, + "esnext": true, + "evil": false, + "expr": true, + "forin": false, + "freeze": true, + "funcscope": true, + "gcl": false, + "globals": { + "Promise": true, + "React": true, + "XMLHttpRequest": true, + "document": true, + "location": true, + "window": true + }, + "globalstrict": true, + "immed": false, + "indent": 2, + "iterator": false, + "jquery": false, + "lastsemic": false, + "latedef": false, + "laxbreak": true, + "laxcomma": false, + "loopfunc": false, + "maxcomplexity": false, + "maxdepth": false, + "maxerr": 50, + "maxlen": 80, + "maxparams": false, + "maxstatements": false, + "mootools": false, + "moz": false, + "multistr": false, + "newcap": true, + "noarg": true, + "node": true, + "noempty": false, + "nonbsp": true, + "nonew": true, + "nonstandard": false, + "notypeof": false, + "noyield": false, + "phantom": false, + "plusplus": false, + "predef": [ + "afterEach", + "beforeEach", + "describe", + "expect", + "it", + "jest", + "pit" + ], + "proto": false, + "prototypejs": false, + "quotmark": true, + "rhino": false, + "scripturl": false, + "shadow": false, + "smarttabs": false, + "strict": false, + "sub": false, + "supernew": false, + "trailing": true, + "undef": true, + "unused": true, + "validthis": false, + "worker": false, + "wsh": false, + "yui": false +} diff --git a/packager/react-packager/.npmignore b/packager/react-packager/.npmignore new file mode 100644 index 0000000000..2113f1066d --- /dev/null +++ b/packager/react-packager/.npmignore @@ -0,0 +1,8 @@ +*~ +*.swm +*.swn +*.swp +*.DS_STORE +npm-debug.log +.cache +node_modules diff --git a/packager/react-packager/__mocks__/debug.js b/packager/react-packager/__mocks__/debug.js new file mode 100644 index 0000000000..d35fffd4ae --- /dev/null +++ b/packager/react-packager/__mocks__/debug.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = function() { + return function() {}; +}; diff --git a/packager/react-packager/__mocks__/net.js b/packager/react-packager/__mocks__/net.js new file mode 100644 index 0000000000..661fb196d9 --- /dev/null +++ b/packager/react-packager/__mocks__/net.js @@ -0,0 +1,26 @@ +var EventEmitter = require('events').EventEmitter; +var servers = {}; +exports.createServer = function(listener) { + var server = { + _listener: listener, + + socket: new EventEmitter(), + + listen: function(path) { + listener(this.socket); + servers[path] = this; + } + }; + + server.socket.setEncoding = function() {}; + server.socket.write = function(data) { + this.emit('data', data); + }; + + return server; +}; + +exports.connect = function(options) { + var server = servers[options.path || options.port]; + return server.socket; +}; diff --git a/packager/react-packager/example_project/bar.js b/packager/react-packager/example_project/bar.js new file mode 100644 index 0000000000..cc56ce6ec3 --- /dev/null +++ b/packager/react-packager/example_project/bar.js @@ -0,0 +1,5 @@ +/** + * @providesModule bar + */ + + module.exports = setInterval; \ No newline at end of file diff --git a/packager/react-packager/example_project/config.json b/packager/react-packager/example_project/config.json new file mode 100644 index 0000000000..0acdcb514d --- /dev/null +++ b/packager/react-packager/example_project/config.json @@ -0,0 +1,10 @@ +{ + "port": 3000, + "devPort": 3001, + "publicDir": "./public", + "rootPath": "../example_project", + "moduleOptions": { + "format": "haste", + "main": "index.js" + } +} diff --git a/packager/react-packager/example_project/foo/foo.js b/packager/react-packager/example_project/foo/foo.js new file mode 100644 index 0000000000..c45d9abaad --- /dev/null +++ b/packager/react-packager/example_project/foo/foo.js @@ -0,0 +1,23 @@ +/** + * @providesModule foo + */ + + +var bar = require('bar'); + +class Logger { + log() { + console.log('youll have to change me lol'); + } +} + +class SecretLogger extends Logger { + log(secret) { + console.log('logging ', secret); + } +} + +module.exports = (secret) => { + if (secret !== 'secret') throw new Error('wrong secret'); + bar(new SecretLogger().log.bind(SecretLogger, secret), 400); +}; diff --git a/packager/react-packager/example_project/index.js b/packager/react-packager/example_project/index.js new file mode 100644 index 0000000000..2943d18751 --- /dev/null +++ b/packager/react-packager/example_project/index.js @@ -0,0 +1,10 @@ +/** + * @providesModule index + * @jsx React.DOM + */ + +require('main'); +require('code'); + +var foo = require('foo'); +foo('secret'); diff --git a/packager/react-packager/example_project/js/Channel.js b/packager/react-packager/example_project/js/Channel.js new file mode 100644 index 0000000000..d3cbae1c17 --- /dev/null +++ b/packager/react-packager/example_project/js/Channel.js @@ -0,0 +1,46 @@ +/** + * @providesModule Channel + */ + +var XHR = require('XHR'); + +/** + * Client implementation of a server-push channel. + * + * @see Channel.js for full documentation + */ +var channel = null, at = null, delay = 0; +var Channel = {}; + +Channel.connect = function() { + var url = '/pull'; + if (channel) { + url += '?channel=' + channel + '&at=' + at; + } + XHR.get(url, function(err, xhr) { + if (err) { + delay = Math.min(Math.max(1000, delay * 2), 30000); + } else { + var res = xhr.responseText; + res = JSON.parse(res); + + delay = 0; + + // Cache channel state + channel = res.channel; + at = res.at; + + var messages = res.messages; + messages.forEach(function(message) { + var ev = document.createEvent('CustomEvent'); + ev.initCustomEvent(message.event, true, true, message.detail); + window.dispatchEvent(ev); + }); + } + + // Reconnect + setTimeout(Channel.connect, delay); + }); +}; + +module.exports = Channel; diff --git a/packager/react-packager/example_project/js/XHR.js b/packager/react-packager/example_project/js/XHR.js new file mode 100644 index 0000000000..d9e0563f02 --- /dev/null +++ b/packager/react-packager/example_project/js/XHR.js @@ -0,0 +1,22 @@ +/** + * @providesModule XHR + */ + +function request(method, url, callback) { + var xhr = new XMLHttpRequest(); + xhr.open(method, url); + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + if (xhr.status === 200) { + callback(null, xhr); + } else { + callback(new Error('status = ' + xhr.status, xhr)); + } + } + }; + xhr.send(); +} + +exports.get = function(url, callback) { + request('GET', url, callback); +}; diff --git a/packager/react-packager/example_project/js/code.js b/packager/react-packager/example_project/js/code.js new file mode 100644 index 0000000000..7006785988 --- /dev/null +++ b/packager/react-packager/example_project/js/code.js @@ -0,0 +1,51 @@ +/** + * @providesModule code + */ +var XHR = require('XHR'); + +var $ = function(sel) {return document.querySelector(sel);}; + +function getListItems(files) { + var items = []; + files.forEach(function(file) { + var displayName = file.name + (file.type == 1 ? '/' : ''); + items.push( + React.DOM.li({ + className: 'type' + file.type, + key: file.ino + }, displayName) + ); + if (file.type === 1) { + items.push(getListItems(file.nodes)); + } + }); + + return React.DOM.ol(null, items); +} + +var FileList = React.createClass({ + getInitialState: function() { + return {files: []}; + }, + + componentDidMount: function() { + XHR.get( + this.props.source, + function(err, xhr) { + if (err) {throw err;} + + var files = JSON.parse(xhr.responseText); + this.setState({files: files}); + }.bind(this) + ); + }, + + render: function() { + return getListItems(this.state.files); + } +}); + +window.addEventListener('load', function() { + React.render(React.createElement(FileList, {source: '/files'}), + $('#code')); +}); diff --git a/packager/react-packager/example_project/js/main.js b/packager/react-packager/example_project/js/main.js new file mode 100644 index 0000000000..58847092de --- /dev/null +++ b/packager/react-packager/example_project/js/main.js @@ -0,0 +1,57 @@ +/** + * @providesModule main + */ +var Channel = require('Channel'); + +function toArray(arr) {return Array.prototype.slice.apply(arr);} +function $(sel) {return document.querySelector(sel);} +function $$(sel) {return toArray(document.querySelectorAll(sel));} + +window.addEventListener('load', function() { + function channelLog() { + var args = Array.prototype.slice.apply(arguments); + var ts = new Date(); + var el = document.createElement('li'); + args.unshift(ts.getHours() + ':' + + ('0' + ts.getMinutes()).substr(0,2) + ':' + + ('0' + ts.getSeconds()).substr(0,2)); + el.className = 'console-entry'; + el.innerHTML = args.join(' '); + $('#console').appendChild(el); + el.scrollIntoView(); + } + + global.addEventListener('ChannelInit', function(event) { + $('#console').innerHTML = ''; + channelLog(event.type); + }); + + global.addEventListener('ChannelLog', function(event) { + channelLog.apply(null, event.detail); + }); + + // Tab pane support + function showTab(paneId) { + paneId = paneId.replace(/\W/g, ''); + if (paneId) { + $$('#nav-panes > div').forEach(function(pane) { + pane.classList.toggle('active', pane.id === paneId); + }); + $$('#nav-tabs li').forEach(function(tab) { + tab.classList.toggle('active', + tab.getAttribute('data-pane') === paneId); + }); + global.history.replaceState(null, null, '#' + paneId); + } + } + + $('#nav-tabs').onclick = function(e) { + showTab(e.target.getAttribute('data-pane')); + }; + + // Show current pane + showTab(location.hash); + + // Connect to server-push channel + Channel.connect(); +}); diff --git a/packager/react-packager/example_project/public/css/index.css b/packager/react-packager/example_project/public/css/index.css new file mode 100644 index 0000000000..7d36bf2ce4 --- /dev/null +++ b/packager/react-packager/example_project/public/css/index.css @@ -0,0 +1,94 @@ +html { + font-family: sans-serif; +} +body { + margin-right: 200px +} + +#nav-tabs { + margin: 0; + padding: 0; + position: absolute; + top: 0px; + left: 0px; + right: 0px; + background-color: #eee; + border-bottom: solid 1px black; + font-size: 10pt; + font-weight: bold; + vertical-align: bottom; + line-height: 20px; + height: 29px; +} +#nav-tabs li { + padding: 0 10px; + margin: 0; + border-bottom-width: 0; + display:inline-block; + cursor: pointer; + line-height: 29px; +} +#nav-tabs li:first-child { + color: #666; +} +#nav-tabs li.active { + background-color: #fff; +} + +#nav-panes { + position: absolute; + top: 30px; + left: 0px; + right: 0px; + bottom: 0px; + scroll: auto; + overflow: auto; + background-color: #fff; +} + +#nav-panes .pane { + display: none; +} +#nav-panes .active { + display: block; +} + +.pane { + padding: 10px; +} + +#console { + padding-left: 5px; +} +#console li { + font-size: 10pt; + font-family: monospace; + white-space: nowrap; + margin: 0; + list-style: none; +} + +#code > ol { + font-size: 10pt; + font-family: monospace; + margin: 0; + padding: 0; + cursor: pointer; +} +#code ol ol { + margin-left: 1em; + padding-left: 1em; + border-left: dashed 1px #ddd; +} +#code li { + color: #000; + font-weight: normal; + list-style: none; + line-height: 1.2em; +} +#code .type1 { + color: #009; +} +#code .type2 { + color: #909; +} diff --git a/packager/react-packager/example_project/public/index.html b/packager/react-packager/example_project/public/index.html new file mode 100644 index 0000000000..b19685d570 --- /dev/null +++ b/packager/react-packager/example_project/public/index.html @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + diff --git a/packager/react-packager/index.js b/packager/react-packager/index.js new file mode 100644 index 0000000000..65ae88d8d5 --- /dev/null +++ b/packager/react-packager/index.js @@ -0,0 +1,39 @@ +'use strict'; + +var Activity = require('./src/Activity'); +var Server = require('./src/Server'); + +exports.middleware = function(options) { + var server = new Server(options); + return server.processRequest.bind(server); +}; + +exports.buildPackageFromUrl = function(options, reqUrl) { + Activity.disable(); + // Don't start the filewatcher or the cache. + if (options.nonPersistent == null) { + options.nonPersistent = true; + } + + var server = new Server(options); + return server.buildPackageFromUrl(reqUrl) + .then(function(p) { + server.end(); + return p; + }); +}; + +exports.getDependencies = function(options, main) { + Activity.disable(); + // Don't start the filewatcher or the cache. + if (options.nonPersistent == null) { + options.nonPersistent = true; + } + + var server = new Server(options); + return server.getDependencies(main) + .then(function(r) { + server.end(); + return r.dependencies; + }); +}; diff --git a/packager/react-packager/package.json b/packager/react-packager/package.json new file mode 100644 index 0000000000..ad7b760295 --- /dev/null +++ b/packager/react-packager/package.json @@ -0,0 +1,10 @@ +{ + "name": "react-packager", + "version": "0.1.0", + "description": "", + "main": "index.js", + "jest": { + "unmockedModulePathPatterns": ["source-map"], + "testPathIgnorePatterns": ["JSAppServer/node_modules"] + } +} diff --git a/packager/react-packager/src/Activity/__tests__/Activity-test.js b/packager/react-packager/src/Activity/__tests__/Activity-test.js new file mode 100644 index 0000000000..7a2bdf4813 --- /dev/null +++ b/packager/react-packager/src/Activity/__tests__/Activity-test.js @@ -0,0 +1,79 @@ +jest.autoMockOff(); + +describe('Activity', function() { + var Activity; + + var origConsoleLog = console.log; + + beforeEach(function() { + console.log = jest.genMockFn(); + Activity = require('../'); + }); + + afterEach(function() { + console.log = origConsoleLog; + }); + + describe('startEvent', function() { + it('writes a START event out to the console', function() { + var EVENT_NAME = 'EVENT_NAME'; + var DATA = {someData: 42}; + + Activity.startEvent(EVENT_NAME, DATA); + jest.runOnlyPendingTimers(); + + expect(console.log.mock.calls.length).toBe(1); + var consoleMsg = console.log.mock.calls[0][0]; + expect(consoleMsg).toContain('START'); + expect(consoleMsg).toContain(EVENT_NAME); + expect(consoleMsg).toContain(JSON.stringify(DATA)); + }); + }); + + describe('endEvent', function() { + it('writes an END event out to the console', function() { + var EVENT_NAME = 'EVENT_NAME'; + var DATA = {someData: 42}; + + var eventID = Activity.startEvent(EVENT_NAME, DATA); + Activity.endEvent(eventID); + jest.runOnlyPendingTimers(); + + expect(console.log.mock.calls.length).toBe(2); + var consoleMsg = console.log.mock.calls[1][0]; + expect(consoleMsg).toContain('END'); + expect(consoleMsg).toContain(EVENT_NAME); + expect(consoleMsg).toContain(JSON.stringify(DATA)); + }); + + it('throws when called with an invalid eventId', function() { + expect(function() { + Activity.endEvent(42); + }).toThrow('event(42) is not a valid event id!'); + }); + + it('throws when called with an expired eventId', function() { + var eid = Activity.startEvent('', ''); + Activity.endEvent(eid); + + expect(function() { + Activity.endEvent(eid); + }).toThrow('event(1) has already ended!'); + }); + }); + + describe('signal', function() { + it('writes a SIGNAL event out to the console', function() { + var EVENT_NAME = 'EVENT_NAME'; + var DATA = {someData: 42}; + + Activity.signal(EVENT_NAME, DATA); + jest.runOnlyPendingTimers(); + + expect(console.log.mock.calls.length).toBe(1); + var consoleMsg = console.log.mock.calls[0][0]; + expect(consoleMsg).toContain(EVENT_NAME); + expect(consoleMsg).toContain(JSON.stringify(DATA)); + }); + }); +}); diff --git a/packager/react-packager/src/Activity/index.js b/packager/react-packager/src/Activity/index.js new file mode 100644 index 0000000000..a60f87b085 --- /dev/null +++ b/packager/react-packager/src/Activity/index.js @@ -0,0 +1,161 @@ +var COLLECTION_PERIOD = 1000; + +var _endedEvents = Object.create(null); +var _eventStarts = Object.create(null); +var _queuedActions = []; +var _scheduledCollectionTimer = null; +var _uuid = 1; +var _enabled = true; + +function endEvent(eventId) { + var eventEndTime = Date.now(); + + if (!_eventStarts[eventId]) { + _throw('event(' + eventId + ') is not a valid event id!'); + } + + if (_endedEvents[eventId]) { + _throw('event(' + eventId + ') has already ended!'); + } + + _scheduleAction({ + action: 'endEvent', + eventId: eventId, + tstamp: eventEndTime + }); + _endedEvents[eventId] = true; +} + +function signal(eventName, data) { + var signalTime = Date.now(); + + if (eventName == null) { + _throw('No event name specified'); + } + + if (data == null) { + data = null; + } + + _scheduleAction({ + action: 'signal', + data: data, + eventName: eventName, + tstamp: signalTime + }); +} + +function startEvent(eventName, data) { + var eventStartTime = Date.now(); + + if (eventName == null) { + _throw('No event name specified'); + } + + if (data == null) { + data = null; + } + + var eventId = _uuid++; + var action = { + action: 'startEvent', + data: data, + eventId: eventId, + eventName: eventName, + tstamp: eventStartTime, + }; + _scheduleAction(action); + _eventStarts[eventId] = action; + + return eventId; +} + +function disable() { + _enabled = false; +} + +function _runCollection() { + /* jshint -W084 */ + var action; + while ((action = _queuedActions.shift())) { + _writeAction(action); + } + + _scheduledCollectionTimer = null; +} + +function _scheduleAction(action) { + _queuedActions.push(action); + + if (_scheduledCollectionTimer === null) { + _scheduledCollectionTimer = setTimeout(_runCollection, COLLECTION_PERIOD); + } +} + +/** + * This a utility function that throws an error message. + * + * The only purpose of this utility is to make APIs like + * startEvent/endEvent/signal inlineable in the JIT. + * + * (V8 can't inline functions that statically contain a `throw`, and probably + * won't be adding such a non-trivial optimization anytime soon) + */ +function _throw(msg) { + var err = new Error(msg); + + // Strip off the call to _throw() + var stack = err.stack.split('\n'); + stack.splice(1, 1); + err.stack = stack.join('\n'); + + throw err; +} + +function _writeAction(action) { + if (!_enabled) { + return; + } + + var data = action.data ? ': ' + JSON.stringify(action.data) : ''; + var fmtTime = new Date(action.tstamp).toLocaleTimeString(); + + switch (action.action) { + case 'startEvent': + console.log( + '[' + fmtTime + '] ' + + ' ' + action.eventName + + data + ); + break; + + case 'endEvent': + var startAction = _eventStarts[action.eventId]; + var startData = startAction.data ? ': ' + JSON.stringify(startAction.data) : ''; + console.log( + '[' + fmtTime + '] ' + + ' ' + startAction.eventName + + '(' + (action.tstamp - startAction.tstamp) + 'ms)' + + startData + ); + delete _eventStarts[action.eventId]; + break; + + case 'signal': + console.log( + '[' + fmtTime + '] ' + + ' ' + action.eventName + '' + + data + ); + break; + + default: + _throw('Unexpected scheduled action type: ' + action.action); + } +} + + +exports.endEvent = endEvent; +exports.signal = signal; +exports.startEvent = startEvent; +exports.disable = disable; diff --git a/packager/react-packager/src/DependencyResolver/ModuleDescriptor.js b/packager/react-packager/src/DependencyResolver/ModuleDescriptor.js new file mode 100644 index 0000000000..0898767a8c --- /dev/null +++ b/packager/react-packager/src/DependencyResolver/ModuleDescriptor.js @@ -0,0 +1,34 @@ +function ModuleDescriptor(fields) { + if (!fields.id) { + throw new Error('Missing required fields id'); + } + this.id = fields.id; + + if (!fields.path) { + throw new Error('Missing required fields path'); + } + this.path = fields.path; + + if (!fields.dependencies) { + throw new Error('Missing required fields dependencies'); + } + this.dependencies = fields.dependencies; + + this.resolveDependency = fields.resolveDependency; + + this.entry = fields.entry || false; + + this.isPolyfill = fields.isPolyfill || false; + + this._fields = fields; +} + +ModuleDescriptor.prototype.toJSON = function() { + return { + id: this.id, + path: this.path, + dependencies: this.dependencies + } +}; + +module.exports = ModuleDescriptor; diff --git a/packager/react-packager/src/DependencyResolver/haste/DependencyGraph/__mocks__/fs.js b/packager/react-packager/src/DependencyResolver/haste/DependencyGraph/__mocks__/fs.js new file mode 100644 index 0000000000..de3622d931 --- /dev/null +++ b/packager/react-packager/src/DependencyResolver/haste/DependencyGraph/__mocks__/fs.js @@ -0,0 +1,101 @@ +'use strict'; + +var fs = jest.genMockFromModule('fs'); + +fs.realpath.mockImpl(function(filepath, callback) { + var node; + try { + node = getToNode(filepath); + } catch (e) { + return callback(e); + } + if (node && typeof node === 'object' && node.SYMLINK != null) { + return callback(null, node.SYMLINK); + } + callback(null, filepath); +}); + +fs.readdir.mockImpl(function(filepath, callback) { + var node; + try { + node = getToNode(filepath); + if (node && typeof node === 'object' && node.SYMLINK != null) { + node = getToNode(node.SYMLINK); + } + } catch (e) { + return callback(e); + } + + if (!(node && typeof node === 'object' && node.SYMLINK == null)) { + return callback(new Error(filepath + ' is not a directory.')); + } + + callback(null, Object.keys(node)); +}); + +fs.readFile.mockImpl(function(filepath, encoding, callback) { + try { + var node = getToNode(filepath); + // dir check + if (node && typeof node === 'object' && node.SYMLINK == null) { + callback(new Error('Trying to read a dir, ESIDR, or whatever')); + } + return callback(null, node); + } catch (e) { + return callback(e); + } +}); + +fs.lstat.mockImpl(function(filepath, callback) { + var node; + try { + node = getToNode(filepath); + } catch (e) { + return callback(e); + } + + if (node && typeof node === 'object' && node.SYMLINK == null) { + callback(null, { + isDirectory: function() { + return true; + }, + isSymbolicLink: function() { + return false; + } + }); + } else { + callback(null, { + isDirectory: function() { + return false; + }, + isSymbolicLink: function() { + if (typeof node === 'object' && node.SYMLINK) { + return true; + } + return false; + } + }); + } +}); + +var filesystem; + +fs.__setMockFilesystem = function(object) { + filesystem = object; + return filesystem; +}; + +function getToNode(filepath) { + var parts = filepath.split('/'); + if (parts[0] !== '') { + throw new Error('Make sure all paths are absolute.'); + } + var node = filesystem; + parts.slice(1).forEach(function(part) { + node = node[part]; + }); + + return node; +} + +module.exports = fs; diff --git a/packager/react-packager/src/DependencyResolver/haste/DependencyGraph/__tests__/DependencyGraph-test.js b/packager/react-packager/src/DependencyResolver/haste/DependencyGraph/__tests__/DependencyGraph-test.js new file mode 100644 index 0000000000..fe8a18b61e --- /dev/null +++ b/packager/react-packager/src/DependencyResolver/haste/DependencyGraph/__tests__/DependencyGraph-test.js @@ -0,0 +1,731 @@ +'use strict'; + +jest + .dontMock('../index') + .dontMock('q') + .dontMock('path') + .dontMock('absolute-path') + .dontMock('../../../../fb-path-utils') + .dontMock('../docblock') + .setMock('../../../ModuleDescriptor', function(data) {return data;}); + +var q = require('q'); + +describe('DependencyGraph', function() { + var DependencyGraph; + var fileWatcher; + var fs; + + beforeEach(function() { + fs = require('fs'); + DependencyGraph = require('../index'); + + fileWatcher = { + on: function() { + return this; + } + }; + }); + + describe('getOrderedDependencies', function() { + pit('should get dependencies', function() { + var root = '/root'; + fs.__setMockFilesystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("a")' + ].join('\n'), + 'a.js': [ + '/**', + ' * @providesModule a', + ' */', + ].join('\n'), + } + }); + + var dgraph = new DependencyGraph({roots: [root], fileWatcher: fileWatcher}); + return dgraph.load().then(function() { + expect(dgraph.getOrderedDependencies('/root/index.js')) + .toEqual([ + {id: 'index', path: '/root/index.js', dependencies: ['a']}, + {id: 'a', path: '/root/a.js', dependencies: []}, + ]); + }); + }); + + pit('should get recursive dependencies', function() { + var root = '/root'; + fs.__setMockFilesystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("a")', + ].join('\n'), + 'a.js': [ + '/**', + ' * @providesModule a', + ' */', + 'require("index")', + ].join('\n'), + } + }); + + var dgraph = new DependencyGraph({roots: [root], fileWatcher: fileWatcher}); + return dgraph.load().then(function() { + expect(dgraph.getOrderedDependencies('/root/index.js')) + .toEqual([ + {id: 'index', path: '/root/index.js', dependencies: ['a']}, + {id: 'a', path: '/root/a.js', dependencies: ['index']}, + ]); + }); + }); + + pit('should work with packages', function() { + var root = '/root'; + fs.__setMockFilesystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js' + }), + 'main.js': 'lol' + } + } + }); + + var dgraph = new DependencyGraph({roots: [root], fileWatcher: fileWatcher}); + return dgraph.load().then(function() { + expect(dgraph.getOrderedDependencies('/root/index.js')) + .toEqual([ + {id: 'index', path: '/root/index.js', dependencies: ['aPackage']}, + { id: 'aPackage/main', + path: '/root/aPackage/main.js', + dependencies: [] + }, + ]); + }); + }); + + pit('should ignore malformed packages', function() { + var root = '/root'; + fs.__setMockFilesystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': 'lol', + 'main.js': 'lol' + } + } + }); + + var dgraph = new DependencyGraph({roots: [root], fileWatcher: fileWatcher}); + return dgraph.load().then(function() { + expect(dgraph.getOrderedDependencies('/root/index.js')) + .toEqual([ + {id: 'index', path: '/root/index.js', dependencies: ['aPackage']}, + ]); + }); + }); + + pit('can have multiple modules with the same name', function() { + var root = '/root'; + fs.__setMockFilesystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("b")', + ].join('\n'), + 'b.js': [ + '/**', + ' * @providesModule b', + ' */', + ].join('\n'), + 'c.js': [ + '/**', + ' * @providesModule c', + ' */', + ].join('\n'), + 'somedir': { + 'somefile.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("c")', + ].join('\n') + } + } + }); + + var dgraph = new DependencyGraph({roots: [root], fileWatcher: fileWatcher}); + return dgraph.load().then(function() { + expect(dgraph.getOrderedDependencies('/root/somedir/somefile.js')) + .toEqual([ + { id: 'index', + path: '/root/somedir/somefile.js', + dependencies: ['c'] + }, + { id: 'c', + path: '/root/c.js', + dependencies: [] + }, + ]); + }); + }); + + pit('providesModule wins when conflict with package', function() { + var root = '/root'; + fs.__setMockFilesystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + ].join('\n'), + 'b.js': [ + '/**', + ' * @providesModule aPackage', + ' */', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js' + }), + 'main.js': 'lol' + } + } + }); + + var dgraph = new DependencyGraph({roots: [root], fileWatcher: fileWatcher}); + return dgraph.load().then(function() { + expect(dgraph.getOrderedDependencies('/root/index.js')) + .toEqual([ + { id: 'index', + path: '/root/index.js', + dependencies: ['aPackage'] + }, + { id: 'aPackage', + path: '/root/b.js', + dependencies: [] + }, + ]); + }); + }); + + pit('should be forgiving with missing requires', function() { + var root = '/root'; + fs.__setMockFilesystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("lolomg")', + ].join('\n') + } + }); + + var dgraph = new DependencyGraph({roots: [root], fileWatcher: fileWatcher}); + return dgraph.load().then(function() { + expect(dgraph.getOrderedDependencies('/root/index.js')) + .toEqual([ + { id: 'index', + path: '/root/index.js', + dependencies: ['lolomg'] + } + ]); + }); + }); + + pit('should work with packages with subdirs', function() { + var root = '/root'; + fs.__setMockFilesystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage/subdir/lolynot")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js' + }), + 'main.js': 'lol', + 'subdir': { + 'lolynot.js': 'lolynot' + } + } + } + }); + + var dgraph = new DependencyGraph({roots: [root], fileWatcher: fileWatcher}); + return dgraph.load().then(function() { + expect(dgraph.getOrderedDependencies('/root/index.js')) + .toEqual([ + { id: 'index', + path: '/root/index.js', + dependencies: ['aPackage/subdir/lolynot'] + }, + { id: 'aPackage/subdir/lolynot', + path: '/root/aPackage/subdir/lolynot.js', + dependencies: [] + }, + ]); + }); + }); + + pit('should work with packages with symlinked subdirs', function() { + var root = '/root'; + fs.__setMockFilesystem({ + 'symlinkedPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js' + }), + 'main.js': 'lol', + 'subdir': { + 'lolynot.js': 'lolynot' + } + }, + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage/subdir/lolynot")', + ].join('\n'), + 'aPackage': { SYMLINK: '/symlinkedPackage' }, + } + }); + + var dgraph = new DependencyGraph({roots: [root], fileWatcher: fileWatcher}); + return dgraph.load().then(function() { + expect(dgraph.getOrderedDependencies('/root/index.js')) + .toEqual([ + { id: 'index', + path: '/root/index.js', + dependencies: ['aPackage/subdir/lolynot'] + }, + { id: 'aPackage/subdir/lolynot', + path: '/symlinkedPackage/subdir/lolynot.js', + dependencies: [] + }, + ]); + }); + }); + + pit('should work with relative modules in packages', function() { + var root = '/root'; + fs.__setMockFilesystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js' + }), + 'main.js': 'require("./subdir/lolynot")', + 'subdir': { + 'lolynot.js': 'require("../other")' + }, + 'other.js': 'some code' + } + } + }); + + var dgraph = new DependencyGraph({roots: [root], fileWatcher: fileWatcher}); + return dgraph.load().then(function() { + expect(dgraph.getOrderedDependencies('/root/index.js')) + .toEqual([ + { id: 'index', + path: '/root/index.js', + dependencies: ['aPackage'] + }, + { id: 'aPackage/main', + path: '/root/aPackage/main.js', + dependencies: ['./subdir/lolynot'] + }, + { id: 'aPackage/subdir/lolynot', + path: '/root/aPackage/subdir/lolynot.js', + dependencies: ['../other'] + }, + { id: 'aPackage/other', + path: '/root/aPackage/other.js', + dependencies: [] + }, + ]); + }); + }); + }); + + describe('file watch updating', function() { + var fileWatcher; + var triggerFileChange; + + beforeEach(function() { + fileWatcher = { + on: function(eventType, callback) { + if (eventType !== 'all') { + throw new Error('Can only handle "all" event in watcher.'); + } + triggerFileChange = callback; + return this; + } + }; + }); + + pit('updates module dependencies', function() { + var root = '/root'; + var filesystem = fs.__setMockFilesystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + 'require("foo")' + ].join('\n'), + 'foo': [ + '/**', + ' * @providesModule foo', + ' */', + 'require("aPackage")' + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js' + }), + 'main.js': 'main', + } + } + }); + + var dgraph = new DependencyGraph({roots: [root], fileWatcher: fileWatcher}); + return dgraph.load().then(function() { + filesystem.root['index.js'] = + filesystem.root['index.js'].replace('require("foo")', ''); + triggerFileChange('change', 'index.js', root); + return dgraph.load().then(function() { + expect(dgraph.getOrderedDependencies('/root/index.js')) + .toEqual([ + { id: 'index', + path: '/root/index.js', + dependencies: ['aPackage'] + }, + { id: 'aPackage/main', + path: '/root/aPackage/main.js', + dependencies: [] + }, + ]); + }); + }); + }); + + pit('updates module dependencies on file change', function() { + var root = '/root'; + var filesystem = fs.__setMockFilesystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + 'require("foo")' + ].join('\n'), + 'foo.js': [ + '/**', + ' * @providesModule foo', + ' */', + 'require("aPackage")' + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js' + }), + 'main.js': 'main', + } + } + }); + + var dgraph = new DependencyGraph({roots: [root], fileWatcher: fileWatcher}); + return dgraph.load().then(function() { + filesystem.root['index.js'] = + filesystem.root['index.js'].replace('require("foo")', ''); + triggerFileChange('change', 'index.js', root); + return dgraph.load().then(function() { + expect(dgraph.getOrderedDependencies('/root/index.js')) + .toEqual([ + { id: 'index', + path: '/root/index.js', + dependencies: ['aPackage'] + }, + { id: 'aPackage/main', + path: '/root/aPackage/main.js', + dependencies: [] + }, + ]); + }); + }); + }); + + pit('updates module dependencies on file delete', function() { + var root = '/root'; + var filesystem = fs.__setMockFilesystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + 'require("foo")' + ].join('\n'), + 'foo.js': [ + '/**', + ' * @providesModule foo', + ' */', + 'require("aPackage")' + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js' + }), + 'main.js': 'main', + } + } + }); + + var dgraph = new DependencyGraph({roots: [root], fileWatcher: fileWatcher}); + return dgraph.load().then(function() { + delete filesystem.root.foo; + triggerFileChange('delete', 'foo.js', root); + return dgraph.load().then(function() { + expect(dgraph.getOrderedDependencies('/root/index.js')) + .toEqual([ + { id: 'index', + path: '/root/index.js', + dependencies: ['aPackage', 'foo'] + }, + { id: 'aPackage/main', + path: '/root/aPackage/main.js', + dependencies: [] + }, + ]); + }); + }); + }); + + pit('updates module dependencies on file add', function() { + var root = '/root'; + var filesystem = fs.__setMockFilesystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + 'require("foo")' + ].join('\n'), + 'foo.js': [ + '/**', + ' * @providesModule foo', + ' */', + 'require("aPackage")' + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js' + }), + 'main.js': 'main', + } + } + }); + + var dgraph = new DependencyGraph({roots: [root], fileWatcher: fileWatcher}); + return dgraph.load().then(function() { + filesystem.root['bar.js'] = [ + '/**', + ' * @providesModule bar', + ' */', + 'require("foo")' + ].join('\n'); + triggerFileChange('add', 'bar.js', root); + + filesystem.root.aPackage['main.js'] = 'require("bar")'; + triggerFileChange('change', 'aPackage/main.js', root); + + return dgraph.load().then(function() { + expect(dgraph.getOrderedDependencies('/root/index.js')) + .toEqual([ + { id: 'index', + path: '/root/index.js', + dependencies: ['aPackage', 'foo'] + }, + { id: 'aPackage/main', + path: '/root/aPackage/main.js', + dependencies: ['bar'] + }, + { id: 'bar', + path: '/root/bar.js', + dependencies: ['foo'] + }, + { id: 'foo', + path: '/root/foo.js', + dependencies: ['aPackage'] + }, + ]); + }); + }); + }); + + pit('runs changes through ignore filter', function() { + var root = '/root'; + var filesystem = fs.__setMockFilesystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + 'require("foo")' + ].join('\n'), + 'foo.js': [ + '/**', + ' * @providesModule foo', + ' */', + 'require("aPackage")' + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js' + }), + 'main.js': 'main', + } + } + }); + + var dgraph = new DependencyGraph({ + roots: [root], + fileWatcher: fileWatcher, + ignoreFilePath: function(filePath) { + if (filePath === '/root/bar.js') { + return true; + } + return false; + } + }); + return dgraph.load().then(function() { + filesystem.root['bar.js'] = [ + '/**', + ' * @providesModule bar', + ' */', + 'require("foo")' + ].join('\n'); + triggerFileChange('add', 'bar.js', root); + + filesystem.root.aPackage['main.js'] = 'require("bar")'; + triggerFileChange('change', 'aPackage/main.js', root); + + return dgraph.load().then(function() { + expect(dgraph.getOrderedDependencies('/root/index.js')) + .toEqual([ + { id: 'index', + path: '/root/index.js', + dependencies: ['aPackage', 'foo'] + }, + { id: 'aPackage/main', + path: '/root/aPackage/main.js', + dependencies: ['bar'] + }, + { id: 'foo', + path: '/root/foo.js', + dependencies: ['aPackage'] + }, + ]); + }); + }); + }); + + pit('should ignore directory updates', function() { + var root = '/root'; + var filesystem = fs.__setMockFilesystem({ + 'root': { + 'index.js': [ + '/**', + ' * @providesModule index', + ' */', + 'require("aPackage")', + 'require("foo")' + ].join('\n'), + 'foo.js': [ + '/**', + ' * @providesModule foo', + ' */', + 'require("aPackage")' + ].join('\n'), + 'aPackage': { + 'package.json': JSON.stringify({ + name: 'aPackage', + main: 'main.js' + }), + 'main.js': 'main', + } + } + }); + var dgraph = new DependencyGraph({roots: [root], fileWatcher: fileWatcher}); + return dgraph.load().then(function() { + triggerFileChange('change', 'aPackage', '/root', { + isDirectory: function(){ return true; } + }); + return dgraph.load().then(function() { + expect(dgraph.getOrderedDependencies('/root/index.js')) + .toEqual([ + { id: 'index', + path: '/root/index.js', + dependencies: ['aPackage', 'foo'] + }, + { id: 'aPackage/main', + path: '/root/aPackage/main.js', + dependencies: [] + }, + { id: 'foo', + path: '/root/foo.js', + dependencies: ['aPackage'] + }, + ]); + }); + }); + }); + }); +}); diff --git a/packager/react-packager/src/DependencyResolver/haste/DependencyGraph/docblock.js b/packager/react-packager/src/DependencyResolver/haste/DependencyGraph/docblock.js new file mode 100644 index 0000000000..52cac03bab --- /dev/null +++ b/packager/react-packager/src/DependencyResolver/haste/DependencyGraph/docblock.js @@ -0,0 +1,88 @@ +/** + * Copyright 2013 Facebook, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +var docblockRe = /^\s*(\/\*\*(.|\r?\n)*?\*\/)/; + +var ltrimRe = /^\s*/; +/** + * @param {String} contents + * @return {String} + */ +function extract(contents) { + var match = contents.match(docblockRe); + if (match) { + return match[0].replace(ltrimRe, '') || ''; + } + return ''; +} + + +var commentStartRe = /^\/\*\*?/; +var commentEndRe = /\*\/$/; +var wsRe = /[\t ]+/g; +var stringStartRe = /(\r?\n|^) *\*/g; +var multilineRe = /(?:^|\r?\n) *(@[^\r\n]*?) *\r?\n *([^@\r\n\s][^@\r\n]+?) *\r?\n/g; +var propertyRe = /(?:^|\r?\n) *@(\S+) *([^\r\n]*)/g; + +/** + * @param {String} contents + * @return {Array} + */ +function parse(docblock) { + docblock = docblock + .replace(commentStartRe, '') + .replace(commentEndRe, '') + .replace(wsRe, ' ') + .replace(stringStartRe, '$1'); + + // Normalize multi-line directives + var prev = ''; + while (prev != docblock) { + prev = docblock; + docblock = docblock.replace(multilineRe, "\n$1 $2\n"); + } + docblock = docblock.trim(); + + var result = []; + var match; + while (match = propertyRe.exec(docblock)) { + result.push([match[1], match[2]]); + } + + return result; +} + +/** + * Same as parse but returns an object of prop: value instead of array of paris + * If a property appers more than once the last one will be returned + * + * @param {String} contents + * @return {Object} + */ +function parseAsObject(docblock) { + var pairs = parse(docblock); + var result = {}; + for (var i = 0; i < pairs.length; i++) { + result[pairs[i][0]] = pairs[i][1]; + } + return result; +} + + +exports.extract = extract; +exports.parse = parse; +exports.parseAsObject = parseAsObject; diff --git a/packager/react-packager/src/DependencyResolver/haste/DependencyGraph/example.js b/packager/react-packager/src/DependencyResolver/haste/DependencyGraph/example.js new file mode 100644 index 0000000000..02e6c5928b --- /dev/null +++ b/packager/react-packager/src/DependencyResolver/haste/DependencyGraph/example.js @@ -0,0 +1,25 @@ +var path = require('path'); +var DependecyGraph = require('./'); + +var example_project = path.resolve(__dirname, '../../../../example_project'); +var watcher = new (require('../../../FileWatcher'))({projectRoot: example_project}); +var graph = new DependecyGraph({ + fileWatcher: watcher, + root: example_project +}); + +graph.load().then(function() { + var index = path.join(example_project, 'index.js'); + console.log(graph.getOrderedDependencies(index)); +}).done(); + +watcher.getWatcher().then(function(watcher) { + watcher.on('all', function() { + setImmediate(function() { + graph.load().then(function() { + var index = path.join(example_project, 'index.js'); + console.log(graph.getOrderedDependencies(index)); + }); + }) + }); +}); diff --git a/packager/react-packager/src/DependencyResolver/haste/DependencyGraph/index.js b/packager/react-packager/src/DependencyResolver/haste/DependencyGraph/index.js new file mode 100644 index 0000000000..9a939620fe --- /dev/null +++ b/packager/react-packager/src/DependencyResolver/haste/DependencyGraph/index.js @@ -0,0 +1,494 @@ +'use strict'; + +var ModuleDescriptor = require('../../ModuleDescriptor'); +var q = require('q'); +var fs = require('fs'); +var docblock = require('./docblock'); +var path = require('path'); +var isAbsolutePath = require('absolute-path'); +var debug = require('debug')('DependecyGraph'); +var util = require('util'); + +var readFile = q.nfbind(fs.readFile); +var readDir = q.nfbind(fs.readdir); +var lstat = q.nfbind(fs.lstat); +var realpath = q.nfbind(fs.realpath); + +function DependecyGraph(options) { + this._roots = options.roots; + this._ignoreFilePath = options.ignoreFilePath || function(){}; + this._loaded = false; + this._queue = this._roots.slice(); + this._graph = Object.create(null); + this._packageByRoot = Object.create(null); + this._packagesById = Object.create(null); + this._moduleById = Object.create(null); + this._debugUpdateEvents = []; + this._fileWatcher = options.fileWatcher; + + // Kick off the search process to precompute the dependency graph. + this._init(); +} + +DependecyGraph.prototype.load = function() { + return this._loading || (this._loading = this._search()); +}; + +/** + * Given an entry file return an array of all the dependent module descriptors. + */ +DependecyGraph.prototype.getOrderedDependencies = function(entryPath) { + var absolutePath = this._getAbsolutePath(entryPath); + if (absolutePath == null) { + throw new Error('Cannot find entry file in any of the roots: ' + entryPath); + } + + var module = this._graph[absolutePath]; + if (module == null) { + throw new Error('Module with path "' + entryPath + '" is not in graph'); + } + + var self = this; + var deps = []; + var visited = Object.create(null); + + // Node haste sucks. Id's aren't unique. So to make sure our entry point + // is the thing that ends up in our dependency list. + var graphMap = Object.create(this._moduleById); + graphMap[module.id] = module; + + // Recursively collect the dependency list. + function collect(module) { + deps.push(module); + + module.dependencies.forEach(function(name) { + var id = sansExtJs(name); + var dep = self.resolveDependency(module, id); + + if (dep == null) { + debug( + 'WARNING: Cannot find required module `%s` from module `%s`.', + name, + module.id + ); + return; + } + + if (!visited[dep.id]) { + visited[dep.id] = true; + collect(dep); + } + }); + } + + visited[module.id] = true; + collect(module); + + return deps; +}; + +/** + * Given a module descriptor `fromModule` return the module descriptor for + * the required module `depModuleId`. It could be top-level or relative, + * or both. + */ +DependecyGraph.prototype.resolveDependency = function( + fromModule, + depModuleId +) { + var packageJson, modulePath, dep; + + // Package relative modules starts with '.' or '..'. + if (depModuleId[0] !== '.') { + + // 1. `depModuleId` is simply a top-level `providesModule`. + // 2. `depModuleId` is a package module but given the full path from the + // package, i.e. package_name/module_name + if (this._moduleById[sansExtJs(depModuleId)]) { + return this._moduleById[sansExtJs(depModuleId)]; + } + + // 3. `depModuleId` is a package and it's depending on the "main" + // resolution. + packageJson = this._packagesById[depModuleId]; + + // We are being forgiving here and raising an error because we could be + // processing a file that uses it's own require system. + if (packageJson == null) { + debug( + 'WARNING: Cannot find required module `%s` from module `%s`.', + depModuleId, + fromModule.id + ); + return; + } + + var main = packageJson.main || 'index'; + modulePath = withExtJs(path.join(packageJson._root, main)); + dep = this._graph[modulePath]; + if (dep == null) { + throw new Error( + 'Cannot find package main file for pacakge: ' + packageJson._root + ); + } + return dep; + } else { + + // 4. `depModuleId` is a module defined in a package relative to + // `fromModule`. + packageJson = this._lookupPackage(fromModule.path); + + if (packageJson == null) { + throw new Error( + 'Expected relative module lookup from ' + fromModule.id + ' to ' + + depModuleId + ' to be within a package but no package.json found.' + ); + } + + // Example: depModuleId: ../a/b + // fromModule.path: /x/y/z + // modulePath: /x/y/a/b + var dir = path.dirname(fromModule.path); + modulePath = withExtJs(path.join(dir, depModuleId)); + + dep = this._graph[modulePath]; + if (dep == null) { + debug( + 'WARNING: Cannot find required module `%s` from module `%s`.' + + ' Inferred required module path is %s', + depModuleId, + fromModule.id, + modulePath + ); + return null; + } + + return dep; + } +}; + +/** + * Intiates the filewatcher and kicks off the search process. + */ +DependecyGraph.prototype._init = function() { + var processChange = this._processFileChange.bind(this); + var watcher = this._fileWatcher; + + this._loading = this.load().then(function() { + watcher.on('all', processChange); + }); +}; + +/** + * Implements a DFS over the file system looking for modules and packages. + */ +DependecyGraph.prototype._search = function() { + var self = this; + var dir = this._queue.shift(); + + if (dir == null) { + return q.Promise.resolve(this._graph); + } + + // Steps: + // 1. Read a dir and stat all the entries. + // 2. Filter the files and queue up the directories. + // 3. Process any package.json in the files + // 4. recur. + return readDir(dir) + .then(function(files){ + return q.all(files.map(function(filePath) { + return realpath(path.join(dir, filePath)).catch(handleBrokenLink); + })); + }) + .then(function(filePaths) { + filePaths = filePaths.filter(function(filePath) { + if (filePath == null) { + return false + } + + return !self._ignoreFilePath(filePath); + }); + + var statsP = filePaths.map(function(filePath) { + return lstat(filePath).catch(handleBrokenLink); + }); + + return [ + filePaths, + q.all(statsP) + ]; + }) + .spread(function(files, stats) { + var modulePaths = files.filter(function(filePath, i) { + if (stats[i].isDirectory()) { + self._queue.push(filePath); + return false; + } + + if (stats[i].isSymbolicLink()) { + return false; + } + + return filePath.match(/\.js$/); + }); + + var processing = self._findAndProcessPackage(files, dir) + .then(function() { + return q.all(modulePaths.map(self._processModule.bind(self))); + }); + + return q.all([ + processing, + self._search() + ]); + }) + .then(function() { + return self; + }); +}; + +/** + * Given a list of files find a `package.json` file, and if found parse it + * and update indices. + */ +DependecyGraph.prototype._findAndProcessPackage = function(files, root) { + var self = this; + + var packagePath; + for (var i = 0; i < files.length ; i++) { + var file = files[i]; + if (path.basename(file) === 'package.json') { + packagePath = file; + break; + } + } + + if (packagePath != null) { + return readFile(packagePath, 'utf8') + .then(function(content) { + var packageJson; + try { + packageJson = JSON.parse(content); + } catch (e) { + debug('WARNING: malformed package.json: ', packagePath); + return q(); + } + + if (packageJson.name == null) { + debug( + 'WARNING: package.json `%s` is missing a name field', + packagePath + ); + return q(); + } + + packageJson._root = root; + self._packageByRoot[root] = packageJson; + self._packagesById[packageJson.name] = packageJson; + + return packageJson; + }); + } else { + return q(); + } +}; + +/** + * Parse a module and update indices. + */ +DependecyGraph.prototype._processModule = function(modulePath) { + var self = this; + return readFile(modulePath, 'utf8') + .then(function(content) { + var moduleDocBlock = docblock.parseAsObject(content); + var moduleData = { path: path.resolve(modulePath) }; + if (moduleDocBlock.providesModule || moduleDocBlock.provides) { + moduleData.id = + moduleDocBlock.providesModule || moduleDocBlock.provides; + } else { + moduleData.id = self._lookupName(modulePath); + } + moduleData.dependencies = extractRequires(content); + + var module = new ModuleDescriptor(moduleData); + self._updateGraphWithModule(module); + return module; + }); +}; + +/** + * Compute the name of module relative to a package it may belong to. + */ +DependecyGraph.prototype._lookupName = function(modulePath) { + var packageJson = this._lookupPackage(modulePath); + if (packageJson == null) { + return path.resolve(modulePath); + } else { + var relativePath = + sansExtJs(path.relative(packageJson._root, modulePath)); + return path.join(packageJson.name, relativePath); + } +}; + +DependecyGraph.prototype._deleteModule = function(module) { + delete this._graph[module.path]; + + // Others may keep a reference so we mark it as deleted. + module.deleted = true; + + // Haste allows different module to have the same id. + if (this._moduleById[module.id] === module) { + delete this._moduleById[module.id]; + } +}; + +/** + * Update the graph and indices with the module. + */ +DependecyGraph.prototype._updateGraphWithModule = function(module) { + if (this._graph[module.path]) { + this._deleteModule(this._graph[module.path]); + } + + this._graph[module.path] = module; + + if (this._moduleById[module.id]) { + debug( + 'WARNING: Top-level module name conflict `%s`.\n' + + 'module with path `%s` will replace `%s`', + module.id, + module.path, + this._moduleById[module.id].path + ); + } + + this._moduleById[module.id] = module; +}; + +/** + * Find the nearest package to a module. + */ +DependecyGraph.prototype._lookupPackage = function(modulePath) { + var packageByRoot = this._packageByRoot; + + /** + * Auxiliary function to recursively lookup a package. + */ + function lookupPackage(currDir) { + // ideally we stop once we're outside root and this can be a simple child + // dir check. However, we have to support modules that was symlinked inside + // our project root. + if (currDir === '/') { + return null; + } else { + var packageJson = packageByRoot[currDir]; + if (packageJson) { + return packageJson; + } else { + return lookupPackage(path.dirname(currDir)); + } + } + } + + return lookupPackage(path.dirname(modulePath)); +}; + +/** + * Process a filewatcher change event. + */ +DependecyGraph.prototype._processFileChange = function(eventType, filePath, root, stat) { + var absPath = path.join(root, filePath); + if (this._ignoreFilePath(absPath)) { + return; + } + + this._debugUpdateEvents.push({event: eventType, path: filePath}); + + if (eventType === 'delete') { + var module = this._graph[absPath]; + if (module == null) { + return; + } + + this._deleteModule(module); + } else if (!(stat && stat.isDirectory())) { + var self = this; + this._loading = this._loading.then(function() { + return self._processModule(absPath); + }); + } +}; + +DependecyGraph.prototype.getDebugInfo = function() { + return '

FileWatcher Update Events

' + + '
' + util.inspect(this._debugUpdateEvents) + '
' + + '

Graph dump

' + + '
' + util.inspect(this._graph) + '
'; +}; + +/** + * Searches all roots for the file and returns the first one that has file of the same path. + */ +DependecyGraph.prototype._getAbsolutePath = function(filePath) { + if (isAbsolutePath(filePath)) { + return filePath; + } + + for (var i = 0, root; root = this._roots[i]; i++) { + var absPath = path.join(root, filePath); + if (this._graph[absPath]) { + return absPath; + } + } + + return null; +}; + +/** + * Extract all required modules from a `code` string. + */ +var requireRe = /\brequire\s*\(\s*[\'"]([^"\']+)["\']\s*\)/g; +var blockCommentRe = /\/\*(.|\n)*?\*\//g; +var lineCommentRe = /\/\/.+(\n|$)/g; +function extractRequires(code) { + var deps = []; + + code + .replace(blockCommentRe, '') + .replace(lineCommentRe, '') + .replace(requireRe, function(match, dep) { + deps.push(dep); + }); + + return deps; +} + +/** + * `file` without the .js extension. + */ +function sansExtJs(file) { + if (file.match(/\.js$/)) { + return file.slice(0, -3); + } else { + return file; + } +} + +/** + * `file` with the .js extension. + */ +function withExtJs(file) { + if (file.match(/\.js$/)) { + return file; + } else { + return file + '.js'; + } +} + +function handleBrokenLink(e) { + debug('WARNING: error stating, possibly broken symlink', e.message); + return q(); +} + +module.exports = DependecyGraph; diff --git a/packager/react-packager/src/DependencyResolver/haste/__tests__/HasteDependencyResolver-test.js b/packager/react-packager/src/DependencyResolver/haste/__tests__/HasteDependencyResolver-test.js new file mode 100644 index 0000000000..9b43f97ee3 --- /dev/null +++ b/packager/react-packager/src/DependencyResolver/haste/__tests__/HasteDependencyResolver-test.js @@ -0,0 +1,195 @@ + +jest.dontMock('../') + .dontMock('q') + .setMock('../../ModuleDescriptor', function(data) {return data;}); + +var q = require('q'); + +describe('HasteDependencyResolver', function() { + var HasteDependencyResolver; + var DependencyGraph; + + beforeEach(function() { + // For the polyfillDeps + require('path').join.mockImpl(function(a, b) { + return b; + }); + HasteDependencyResolver = require('../'); + DependencyGraph = require('../DependencyGraph'); + }); + + describe('getDependencies', function() { + pit('should get dependencies with polyfills', function() { + var module = {id: 'index', path: '/root/index.js', dependencies: ['a']}; + var deps = [module]; + + var depResolver = new HasteDependencyResolver({ + projectRoot: '/root' + }); + + // Is there a better way? How can I mock the prototype instead? + var depGraph = depResolver._depGraph; + depGraph.getOrderedDependencies.mockImpl(function() { + return deps; + }); + depGraph.load.mockImpl(function() { + return q(); + }); + + return depResolver.getDependencies('/root/index.js') + .then(function(result) { + expect(result.mainModuleId).toEqual('index'); + expect(result.dependencies).toEqual([ + { path: 'polyfills/prelude.js', + id: 'polyfills/prelude.js', + isPolyfill: true, + dependencies: [] + }, + { path: 'polyfills/require.js', + id: 'polyfills/require.js', + isPolyfill: true, + dependencies: ['polyfills/prelude.js'] + }, + { path: 'polyfills/polyfills.js', + id: 'polyfills/polyfills.js', + isPolyfill: true, + dependencies: ['polyfills/prelude.js', 'polyfills/require.js'] + }, + { id: 'polyfills/console.js', + isPolyfill: true, + path: 'polyfills/console.js', + dependencies: [ + 'polyfills/prelude.js', + 'polyfills/require.js', + 'polyfills/polyfills.js' + ], + }, + { id: 'polyfills/error-guard.js', + isPolyfill: true, + path: 'polyfills/error-guard.js', + dependencies: [ + 'polyfills/prelude.js', + 'polyfills/require.js', + 'polyfills/polyfills.js', + 'polyfills/console.js' + ], + }, + module + ]); + }); + }); + + pit('should pass in more polyfills', function() { + var module = {id: 'index', path: '/root/index.js', dependencies: ['a']}; + var deps = [module]; + + var depResolver = new HasteDependencyResolver({ + projectRoot: '/root', + polyfillModuleNames: ['some module'] + }); + + // Is there a better way? How can I mock the prototype instead? + var depGraph = depResolver._depGraph; + depGraph.getOrderedDependencies.mockImpl(function() { + return deps; + }); + depGraph.load.mockImpl(function() { + return q(); + }); + + return depResolver.getDependencies('/root/index.js') + .then(function(result) { + expect(result.mainModuleId).toEqual('index'); + expect(result.dependencies).toEqual([ + { path: 'polyfills/prelude.js', + id: 'polyfills/prelude.js', + isPolyfill: true, + dependencies: [] + }, + { path: 'polyfills/require.js', + id: 'polyfills/require.js', + isPolyfill: true, + dependencies: ['polyfills/prelude.js'] + }, + { path: 'polyfills/polyfills.js', + id: 'polyfills/polyfills.js', + isPolyfill: true, + dependencies: ['polyfills/prelude.js', 'polyfills/require.js'] + }, + { id: 'polyfills/console.js', + isPolyfill: true, + path: 'polyfills/console.js', + dependencies: [ + 'polyfills/prelude.js', + 'polyfills/require.js', + 'polyfills/polyfills.js' + ], + }, + { id: 'polyfills/error-guard.js', + isPolyfill: true, + path: 'polyfills/error-guard.js', + dependencies: [ + 'polyfills/prelude.js', + 'polyfills/require.js', + 'polyfills/polyfills.js', + 'polyfills/console.js' + ], + }, + { path: 'some module', + id: 'some module', + isPolyfill: true, + dependencies: [ + 'polyfills/prelude.js', + 'polyfills/require.js', + 'polyfills/polyfills.js', + 'polyfills/console.js', + 'polyfills/error-guard.js', + ] + }, + module + ]); + }); + }); + }); + + describe('wrapModule', function() { + it('should ', function() { + var depResolver = new HasteDependencyResolver({ + projectRoot: '/root' + }); + + var depGraph = depResolver._depGraph; + var dependencies = ['x', 'y', 'z'] + var code = [ + 'require("x")', + 'require("y")', + 'require("z")', + ].join('\n'); + + depGraph.resolveDependency.mockImpl(function(fromModule, toModuleName) { + if (toModuleName === 'x') { + return { + id: 'changed' + }; + } else if (toModuleName === 'y') { + return { id: 'y' }; + } + return null; + }); + + var processedCode = depResolver.wrapModule({ + id: 'test module', + path: '/root/test.js', + dependencies: dependencies + }, code); + + expect(processedCode).toEqual([ + "__d('test module',[\"changed\",\"y\"],function(global," + + " require, requireDynamic, requireLazy, module, exports) {" + + " require('changed')", + "require('y')", + 'require("z")});', + ].join('\n')); + }); + }); +}); diff --git a/packager/react-packager/src/DependencyResolver/haste/index.js b/packager/react-packager/src/DependencyResolver/haste/index.js new file mode 100644 index 0000000000..6e2cd6fcac --- /dev/null +++ b/packager/react-packager/src/DependencyResolver/haste/index.js @@ -0,0 +1,130 @@ +'use strict'; + +var path = require('path'); +var FileWatcher = require('../../FileWatcher'); +var DependencyGraph = require('./DependencyGraph'); +var ModuleDescriptor = require('../ModuleDescriptor'); + +var DEFINE_MODULE_CODE = + '__d(' + + '\'_moduleName_\',' + + '_deps_,' + + 'function(global, require, requireDynamic, requireLazy, module, exports) {'+ + ' _code_' + + '}' + + ');'; + +var DEFINE_MODULE_REPLACE_RE = /_moduleName_|_code_|_deps_/g; + +var REL_REQUIRE_STMT = /require\(['"]([\.\/0-9A-Z_$\-]*)['"]\)/gi; + +function HasteDependencyResolver(config) { + this._fileWatcher = config.nonPersistent + ? FileWatcher.createDummyWatcher() + : new FileWatcher(config.projectRoots); + + this._depGraph = new DependencyGraph({ + roots: config.projectRoots, + ignoreFilePath: function(filepath) { + return filepath.indexOf('__tests__') !== -1 || + (config.blacklistRE && config.blacklistRE.test(filepath)); + }, + fileWatcher: this._fileWatcher + }); + + this._polyfillModuleNames = [ + config.dev + ? path.join(__dirname, 'polyfills/prelude_dev.js') + : path.join(__dirname, 'polyfills/prelude.js'), + path.join(__dirname, 'polyfills/require.js'), + path.join(__dirname, 'polyfills/polyfills.js'), + path.join(__dirname, 'polyfills/console.js'), + path.join(__dirname, 'polyfills/error-guard.js'), + ].concat( + config.polyfillModuleNames || [] + ); +} + +HasteDependencyResolver.prototype.getDependencies = function(main) { + var depGraph = this._depGraph; + var self = this; + + return depGraph.load() + .then(function() { + var dependencies = depGraph.getOrderedDependencies(main); + var mainModuleId = dependencies[0].id; + + self._prependPolyfillDependencies(dependencies); + + return { + mainModuleId: mainModuleId, + dependencies: dependencies + }; + }); +}; + +HasteDependencyResolver.prototype._prependPolyfillDependencies = function( + dependencies +) { + var polyfillModuleNames = this._polyfillModuleNames; + if (polyfillModuleNames.length > 0) { + var polyfillModules = polyfillModuleNames.map( + function(polyfillModuleName, idx) { + return new ModuleDescriptor({ + path: polyfillModuleName, + id: polyfillModuleName, + dependencies: polyfillModuleNames.slice(0, idx), + isPolyfill: true + }); + } + ); + dependencies.unshift.apply(dependencies, polyfillModules); + } +}; + +HasteDependencyResolver.prototype.wrapModule = function(module, code) { + if (module.isPolyfill) { + return code; + } + + var depGraph = this._depGraph; + var resolvedDeps = Object.create(null); + var resolvedDepsArr = []; + + for (var i = 0; i < module.dependencies.length; i++) { + var depName = module.dependencies[i]; + var dep = this._depGraph.resolveDependency(module, depName); + if (dep) { + resolvedDeps[depName] = dep.id; + resolvedDepsArr.push(dep.id); + } + } + + var relativizedCode = + code.replace(REL_REQUIRE_STMT, function(codeMatch, depName) { + var dep = resolvedDeps[depName]; + if (dep != null) { + return 'require(\'' + dep + '\')'; + } else { + return codeMatch; + } + }); + + return DEFINE_MODULE_CODE.replace(DEFINE_MODULE_REPLACE_RE, function(key) { + return { + '_moduleName_': module.id, + '_code_': relativizedCode, + '_deps_': JSON.stringify(resolvedDepsArr), + }[key]; + }); +}; + +HasteDependencyResolver.prototype.end = function() { + return this._fileWatcher.end(); +}; + +HasteDependencyResolver.prototype.getDebugInfo = function() { + return this._depGraph.getDebugInfo(); +}; + +module.exports = HasteDependencyResolver; diff --git a/packager/react-packager/src/DependencyResolver/haste/polyfills/console.js b/packager/react-packager/src/DependencyResolver/haste/polyfills/console.js new file mode 100644 index 0000000000..4c9ddce1ff --- /dev/null +++ b/packager/react-packager/src/DependencyResolver/haste/polyfills/console.js @@ -0,0 +1,141 @@ +/** + * Copyright 2013 Facebook, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * This pipes all of our console logging functions to native logging so that + * JavaScript errors in required modules show up in Xcode via NSLog. + * + * @provides console + * @polyfill + */ + +(function(global) { + + var OBJECT_COLUMN_NAME = '(index)'; + + function setupConsole(global) { + + if (!global.nativeLoggingHook) { + return; + } + + function doNativeLog() { + var str = Array.prototype.map.call(arguments, function(arg) { + if (arg == null) { + return arg === null ? 'null' : 'undefined'; + } else if (typeof arg === 'string') { + return '"' + arg + '"'; + } else { + // Perform a try catch, just in case the object has a circular + // reference or stringify throws for some other reason. + try { + return JSON.stringify(arg); + } catch (e) { + if (typeof arg.toString === 'function') { + try { + return arg.toString(); + } catch (e) { + return 'unknown'; + } + } + } + } + }).join(', '); + global.nativeLoggingHook(str); + }; + + var repeat = function(element, n) { + return Array.apply(null, Array(n)).map(function() { return element; }); + }; + + function consoleTablePolyfill(rows) { + // convert object -> array + if (!Array.isArray(rows)) { + var data = rows; + rows = []; + for (var key in data) { + if (data.hasOwnProperty(key)) { + var row = data[key]; + row[OBJECT_COLUMN_NAME] = key; + rows.push(row); + } + } + } + if (rows.length === 0) { + global.nativeLoggingHook(''); + return; + } + + var columns = Object.keys(rows[0]).sort(); + var stringRows = []; + var columnWidths = []; + + // Convert each cell to a string. Also + // figure out max cell width for each column + columns.forEach(function(k, i) { + columnWidths[i] = k.length; + for (var j = 0; j < rows.length; j++) { + var cellStr = rows[j][k].toString(); + stringRows[j] = stringRows[j] || []; + stringRows[j][i] = cellStr; + columnWidths[i] = Math.max(columnWidths[i], cellStr.length); + } + }); + + // Join all elements in the row into a single string with | separators + // (appends extra spaces to each cell to make separators | alligned) + var joinRow = function(row, space) { + var cells = row.map(function(cell, i) { + var extraSpaces = repeat(' ', columnWidths[i] - cell.length).join(''); + return cell + extraSpaces; + }); + space = space || ' '; + return cells.join(space + '|' + space); + }; + + var separators = columnWidths.map(function(columnWidth) { + return repeat('-', columnWidth).join(''); + }); + var separatorRow = joinRow(separators, '-'); + var header = joinRow(columns); + var table = [header, separatorRow]; + + for (var i = 0; i < rows.length; i++) { + table.push(joinRow(stringRows[i])); + } + + // Notice extra empty line at the beginning. + // Native logging hook adds "RCTLog >" at the front of every + // logged string, which would shift the header and screw up + // the table + global.nativeLoggingHook('\n' + table.join('\n')); + }; + + global.console = { + error: doNativeLog, + info: doNativeLog, + log: doNativeLog, + warn: doNativeLog, + table: consoleTablePolyfill + }; + + }; + + if (typeof module !== 'undefined') { + module.exports = setupConsole; + } else { + setupConsole(global); + } + +})(this); diff --git a/packager/react-packager/src/DependencyResolver/haste/polyfills/error-guard.js b/packager/react-packager/src/DependencyResolver/haste/polyfills/error-guard.js new file mode 100644 index 0000000000..687a4a19c6 --- /dev/null +++ b/packager/react-packager/src/DependencyResolver/haste/polyfills/error-guard.js @@ -0,0 +1,82 @@ + +/** + * The particular require runtime that we are using looks for a global + * `ErrorUtils` object and if it exists, then it requires modules with the + * error handler specified via ErrorUtils.setGlobalHandler by calling the + * require function with applyWithGuard. Since the require module is loaded + * before any of the modules, this ErrorUtils must be defined (and the handler + * set) globally before requiring anything. + */ +/* eslint global-strict:0 */ +(function(global) { + var ErrorUtils = { + _inGuard: 0, + _globalHandler: null, + setGlobalHandler: function(fun) { + ErrorUtils._globalHandler = fun; + }, + reportError: function(error) { + Error._globalHandler && ErrorUtils._globalHandler(error); + }, + applyWithGuard: function(fun, context, args) { + try { + ErrorUtils._inGuard++; + return fun.apply(context, args); + } catch (e) { + ErrorUtils._globalHandler && ErrorUtils._globalHandler(e); + } finally { + ErrorUtils._inGuard--; + } + }, + applyWithGuardIfNeeded: function(fun, context, args) { + if (ErrorUtils.inGuard()) { + return fun.apply(context, args); + } else { + ErrorUtils.applyWithGuard(fun, context, args); + } + }, + inGuard: function() { + return ErrorUtils._inGuard; + }, + guard: function(fun, name, context) { + if (typeof fun !== "function") { + console.warn('A function must be passed to ErrorUtils.guard, got ', fun); + return null; + } + name = name || fun.name || ''; + function guarded() { + return ( + ErrorUtils.applyWithGuard( + fun, + context || this, + arguments, + null, + name + ) + ); + } + + return guarded; + } + }; + global.ErrorUtils = ErrorUtils; + + /** + * This is the error handler that is called when we encounter an exception + * when loading a module. + */ + function setupErrorGuard() { + var onError = function(e) { + global.console.error( + 'Error: ' + + '\n stack: ' + e.stack + + '\n line: ' + e.line + + '\n message: ' + e.message, + e + ); + }; + global.ErrorUtils.setGlobalHandler(onError); + } + + setupErrorGuard(); +})(this); diff --git a/packager/react-packager/src/DependencyResolver/haste/polyfills/polyfills.js b/packager/react-packager/src/DependencyResolver/haste/polyfills/polyfills.js new file mode 100644 index 0000000000..2fd3224638 --- /dev/null +++ b/packager/react-packager/src/DependencyResolver/haste/polyfills/polyfills.js @@ -0,0 +1,75 @@ +/** + * Copyright 2013 Facebook, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * This pipes all of our console logging functions to native logging so that + * JavaScript errors in required modules show up in Xcode via NSLog. + * + * @provides Object.es6 + * @polyfill + */ + +// WARNING: This is an optimized version that fails on hasOwnProperty checks +// and non objects. It's not spec-compliant. It's a perf optimization. + +Object.assign = function(target, sources) { + if (__DEV__) { + if (target == null) { + throw new TypeError('Object.assign target cannot be null or undefined'); + } + if (typeof target !== 'object' && typeof target !== 'function') { + throw new TypeError( + 'In this environment the target of assign MUST be an object.' + + 'This error is a performance optimization and not spec compliant.' + ); + } + } + + for (var nextIndex = 1; nextIndex < arguments.length; nextIndex++) { + var nextSource = arguments[nextIndex]; + if (nextSource == null) { + continue; + } + + if (__DEV__) { + if (typeof nextSource !== 'object' && + typeof nextSource !== 'function') { + throw new TypeError( + 'In this environment the target of assign MUST be an object.' + + 'This error is a performance optimization and not spec compliant.' + ); + } + } + + // We don't currently support accessors nor proxies. Therefore this + // copy cannot throw. If we ever supported this then we must handle + // exceptions and side-effects. + + for (var key in nextSource) { + if (__DEV__) { + var hasOwnProperty = Object.prototype.hasOwnProperty; + if (!hasOwnProperty.call(nextSource, key)) { + throw new TypeError( + 'One of the sources to assign has an enumerable key on the ' + + 'prototype chain. This is an edge case that we do not support. ' + + 'This error is a performance optimization and not spec compliant.' + ); + } + } + target[key] = nextSource[key]; + } + } + + return target; +}; diff --git a/packager/react-packager/src/DependencyResolver/haste/polyfills/prelude.js b/packager/react-packager/src/DependencyResolver/haste/polyfills/prelude.js new file mode 100644 index 0000000000..95c66983b2 --- /dev/null +++ b/packager/react-packager/src/DependencyResolver/haste/polyfills/prelude.js @@ -0,0 +1 @@ +__DEV__ = false; diff --git a/packager/react-packager/src/DependencyResolver/haste/polyfills/prelude_dev.js b/packager/react-packager/src/DependencyResolver/haste/polyfills/prelude_dev.js new file mode 100644 index 0000000000..a5ca53b7a9 --- /dev/null +++ b/packager/react-packager/src/DependencyResolver/haste/polyfills/prelude_dev.js @@ -0,0 +1 @@ +__DEV__ = true; diff --git a/packager/react-packager/src/DependencyResolver/haste/polyfills/require.js b/packager/react-packager/src/DependencyResolver/haste/polyfills/require.js new file mode 100644 index 0000000000..3b5d6d87a3 --- /dev/null +++ b/packager/react-packager/src/DependencyResolver/haste/polyfills/require.js @@ -0,0 +1,626 @@ +(function(global) { + + // avoid redefining require() + if (global.require) { + return; + } + + var __DEV__ = global.__DEV__; + + var toString = Object.prototype.toString; + + /** + * module index: { + * mod1: { + * exports: { ... }, + * id: 'mod1', + * dependencies: ['mod1', 'mod2'], + * factory: function() { ... }, + * waitingMap: { mod1: 1, mod3: 1, mod4: 1 }, + * waiting: 2 + * } + * } + */ + var modulesMap = {}, + /** + * inverse index: { + * mod1: [modules, waiting for mod1], + * mod2: [modules, waiting for mod2] + * } + */ + dependencyMap = {}, + /** + * modules whose reference counts are set out of order + */ + predefinedRefCounts = {}, + + _counter = 0, + + REQUIRE_WHEN_READY = 1, + USED_AS_TRANSPORT = 2, + + hop = Object.prototype.hasOwnProperty; + + function _debugUnresolvedDependencies(names) { + var unresolved = Array.prototype.slice.call(names); + var visited = {}; + var ii, name, module, dependency; + + while (unresolved.length) { + name = unresolved.shift(); + if (visited[name]) { + continue; + } + visited[name] = true; + + module = modulesMap[name]; + if (!module || !module.waiting) { + continue; + } + + for (ii = 0; ii < module.dependencies.length; ii++) { + dependency = module.dependencies[ii]; + if (!modulesMap[dependency] || modulesMap[dependency].waiting) { + unresolved.push(dependency); + } + } + } + + for (name in visited) if (hop.call(visited, name)) { + unresolved.push(name); + } + + var messages = []; + for (ii = 0; ii < unresolved.length; ii++) { + name = unresolved[ii]; + var message = name; + module = modulesMap[name]; + if (!module) { + message += ' is not defined'; + } else if (!module.waiting) { + message += ' is ready'; + } else { + var unresolvedDependencies = []; + for (var jj = 0; jj < module.dependencies.length; jj++) { + dependency = module.dependencies[jj]; + if (!modulesMap[dependency] || modulesMap[dependency].waiting) { + unresolvedDependencies.push(dependency); + } + } + message += ' is waiting for ' + unresolvedDependencies.join(', '); + } + messages.push(message); + } + return messages.join('\n'); + } + + /** + * This is mainly for logging in ModuleErrorLogger. + */ + function ModuleError(msg) { + this.name = 'ModuleError'; + this.message = msg; + this.stack = Error(msg).stack; + this.framesToPop = 2; + } + ModuleError.prototype = Object.create(Error.prototype); + ModuleError.prototype.constructor = ModuleError; + + var _performance = + global.performance || + global.msPerformance || + global.webkitPerformance || {}; + + if (!_performance.now) { + _performance = global.Date; + } + + var _now = _performance ? + _performance.now.bind(_performance) : function(){return 0;}; + + var _factoryStackCount = 0; + var _factoryTime = 0; + var _totalFactories = 0; + + /** + * The require function conforming to CommonJS spec: + * http://wiki.commonjs.org/wiki/Modules/1.1.1 + * + * To define a CommonJS-compliant module add the providesModule + * Haste header to your file instead of @provides. Your file is going + * to be executed in a separate context. Every variable/function you + * define will be local (private) to that module. To export local members + * use "exports" variable or return the exported value at the end of your + * file. Your code will have access to the "module" object. + * The "module" object will have an "id" property that is the id of your + * current module. "module" object will also have "exports" property that + * is the same as "exports" variable passed into your module context. + * You can require other modules using their ids. + * + * Haste will automatically pick dependencies from require() calls. So + * you don't have to manually specify @requires in your header. + * + * You cannot require() modules from non-CommonJS files. Write a legacy stub + * (@providesLegacy) and use @requires instead. + * + * @example + * + * / ** + * * @providesModule math + * * / + * exports.add = function() { + * var sum = 0, i = 0, args = arguments, l = args.length; + * while (i < l) { + * sum += args[i++]; + * } + * return sum; + * }; + * + * / ** + * * @providesModule increment + * * / + * var add = require('math').add; + * return function(val) { + * return add(val, 1); + * }; + * + * / ** + * * @providesModule program + * * / + * var inc = require('increment'); + * var a = 1; + * inc(a); // 2 + * + * module.id == "program"; + * + * + * @param {String} id + * @throws when module is not loaded or not ready to be required + */ + function require(id) { + var module = modulesMap[id], dep, i, msg; + if (module && module.exports) { + // If ref count is 1, this was the last call, so undefine the module. + // The ref count can be null or undefined, but those are never === 1. + if (module.refcount-- === 1) { + delete modulesMap[id]; + } + return module.exports; + } + + if (global.ErrorUtils && !global.ErrorUtils.inGuard()) { + return ErrorUtils.applyWithGuard(require, this, arguments); + } + + if (!module) { + msg = 'Requiring unknown module "' + id + '"'; + if (__DEV__) { + msg += '. It may not be loaded yet. Did you forget to run arc build?'; + } + throw new ModuleError(msg); + } + + if (module.hasError) { + throw new ModuleError( + 'Requiring module "' + id + '" which threw an exception' + ); + } + + if (module.waiting) { + throw new ModuleError( + 'Requiring module "' + id + '" with unresolved dependencies: ' + + _debugUnresolvedDependencies([id]) + ); + } + + var exports = module.exports = {}; + var factory = module.factory; + if (toString.call(factory) === '[object Function]') { + var args = [], + dependencies = module.dependencies, + length = dependencies.length, + ret; + if (module.special & USED_AS_TRANSPORT) { + length = Math.min(length, factory.length); + } + try { + for (i = 0; args.length < length; i++) { + dep = dependencies[i]; + if (!module.inlineRequires[dep]) { + args.push(dep === 'module' ? module : + (dep === 'exports' ? exports : + require.call(null, dep))); + } + } + + ++_totalFactories; + if (_factoryStackCount++ === 0) { + _factoryTime -= _now(); + } + try { + ret = factory.apply(module.context || global, args); + } catch (e) { + if (modulesMap.ex && modulesMap.erx) { + // when ErrorUtils is ready, ex and erx are ready. otherwise, we + // don't append module id to the error message but still throw it + var ex = require.call(null, 'ex'); + var erx = require.call(null, 'erx'); + var messageWithParams = erx(e.message); + if (messageWithParams[0].indexOf(' from module "%s"') < 0) { + messageWithParams[0] += ' from module "%s"'; + messageWithParams[messageWithParams.length] = id; + } + e.message = ex.apply(null, messageWithParams); + } + throw e; + } finally { + if (--_factoryStackCount === 0) { + _factoryTime += _now(); + } + } + } catch (e) { + module.hasError = true; + module.exports = null; + throw e; + } + if (ret) { + if (__DEV__) { + if (typeof ret != 'object' && typeof ret != 'function') { + throw new ModuleError( + 'Factory for module "' + id + '" returned ' + + 'an invalid value "' + ret + '". ' + + 'Returned value should be either a function or an object.' + ); + } + } + module.exports = ret; + } + } else { + module.exports = factory; + } + + // If ref count is 1, this was the last call, so undefine the module. + // The ref count can be null or undefined, but those are never === 1. + if (module.refcount-- === 1) { + delete modulesMap[id]; + } + return module.exports; + } + + require.__getFactoryTime = function() { + return (_factoryStackCount ? _now() : 0) + _factoryTime; + }; + + require.__getTotalFactories = function() { + return _totalFactories; + }; + + /** + * The define function conforming to CommonJS proposal: + * http://wiki.commonjs.org/wiki/Modules/AsynchronousDefinition + * + * define() allows you to explicitly state dependencies of your module + * in javascript. It's most useful in non-CommonJS files. + * + * define() is used internally by haste as a transport for CommonJS + * modules. So there's no need to use define() if you use providesModule + * + * @example + * / ** + * * @provides alpha + * * / + * + * // Sets up the module with ID of "alpha", that uses require, + * // exports and the module with ID of "beta": + * define("alpha", ["require", "exports", "beta"], + * function (require, exports, beta) { + * exports.verb = function() { + * return beta.verb(); + * //Or: + * return require("beta").verb(); + * } + * }); + * + * / ** + * * @provides alpha + * * / + * // An anonymous module could be defined (module id derived from filename) + * // that returns an object literal: + * + * define(["alpha"], function (alpha) { + * return { + * verb: function(){ + * return alpha.verb() + 2; + * } + * }; + * }); + * + * / ** + * * @provides alpha + * * / + * // A dependency-free module can define a direct object literal: + * + * define({ + * add: function(x, y){ + * return x + y; + * } + * }); + * + * @param {String} id optional + * @param {Array} dependencies optional + * @param {Object|Function} factory + */ + function define(id, dependencies, factory, + _special, _context, _refCount, _inlineRequires) { + if (dependencies === undefined) { + dependencies = []; + factory = id; + id = _uid(); + } else if (factory === undefined) { + factory = dependencies; + if (toString.call(id) === '[object Array]') { + dependencies = id; + id = _uid(); + } else { + dependencies = []; + } + } + + // Non-standard: we allow modules to be undefined. This is designed for + // temporary modules. + var canceler = { cancel: _undefine.bind(this, id) }; + + var record = modulesMap[id]; + + // Nonstandard hack: we call define with null deps and factory, but a + // non-null reference count (e.g. define('name', null, null, 0, null, 4)) + // when this module is defined elsewhere and we just need to update the + // reference count. We use this hack to avoid having to expose another + // global function to increment ref counts. + if (record) { + if (_refCount) { + record.refcount += _refCount; + } + // Calling define() on a pre-existing module does not redefine it + return canceler; + } else if (!dependencies && !factory && _refCount) { + // If this module hasn't been defined yet, store the ref count. We'll use + // it when the module is defined later. + predefinedRefCounts[id] = (predefinedRefCounts[id] || 0) + _refCount; + return canceler; + } else { + // Defining a new module + record = { id: id }; + record.refcount = (predefinedRefCounts[id] || 0) + (_refCount || 0); + delete predefinedRefCounts[id]; + } + + if (__DEV__) { + if ( + !factory || + (typeof factory != 'object' && typeof factory != 'function' && + typeof factory != 'string')) { + throw new ModuleError( + 'Invalid factory "' + factory + '" for module "' + id + '". ' + + 'Factory should be either a function or an object.' + ); + } + + if (toString.call(dependencies) !== '[object Array]') { + throw new ModuleError( + 'Invalid dependencies for module "' + id + '". ' + + 'Dependencies must be passed as an array.' + ); + } + } + + record.factory = factory; + record.dependencies = dependencies; + record.context = _context; + record.special = _special; + record.inlineRequires = _inlineRequires || {}; + record.waitingMap = {}; + record.waiting = 0; + record.hasError = false; + modulesMap[id] = record; + _initDependencies(id); + + return canceler; + } + + function _undefine(id) { + if (!modulesMap[id]) { + return; + } + + var module = modulesMap[id]; + delete modulesMap[id]; + + for (var dep in module.waitingMap) { + if (module.waitingMap[dep]) { + delete dependencyMap[dep][id]; + } + } + + for (var ii = 0; ii < module.dependencies.length; ii++) { + dep = module.dependencies[ii]; + if (modulesMap[dep]) { + if (modulesMap[dep].refcount-- === 1) { + _undefine(dep); + } + } else if (predefinedRefCounts[dep]) { + predefinedRefCounts[dep]--; + } + // Subtle: we won't account for this one fewer reference if we don't have + // the dependency's definition or reference count yet. + } + } + + /** + * Special version of define that executes the factory as soon as all + * dependencies are met. + * + * define() does just that, defines a module. Module's factory will not be + * called until required by other module. This makes sense for most of our + * library modules: we do not want to execute the factory unless it's being + * used by someone. + * + * On the other hand there are modules, that you can call "entrance points". + * You want to run the "factory" method for them as soon as all dependencies + * are met. + * + * @example + * + * define('BaseClass', [], function() { return ... }); + * // ^^ factory for BaseClass was just stored in modulesMap + * + * define('SubClass', ['BaseClass'], function() { ... }); + * // SubClass module is marked as ready (waiting == 0), factory is just + * // stored + * + * define('OtherClass, ['BaseClass'], function() { ... }); + * // OtherClass module is marked as ready (waiting == 0), factory is just + * // stored + * + * requireLazy(['SubClass', 'ChatConfig'], + * function() { ... }); + * // ChatRunner is waiting for ChatConfig to come + * + * define('ChatConfig', [], { foo: 'bar' }); + * // at this point ChatRunner is marked as ready, and its factory + * // executed + all dependent factories are executed too: BaseClass, + * // SubClass, ChatConfig notice that OtherClass's factory won't be + * // executed unless explicitly required by someone + * + * @param {Array} dependencies + * @param {Object|Function} factory + */ + function requireLazy(dependencies, factory, context) { + return define( + dependencies, + factory, + undefined, + REQUIRE_WHEN_READY, + context, + 1 + ); + } + + function _uid() { + return '__mod__' + _counter++; + } + + function _addDependency(module, dep) { + // do not add duplicate dependencies and circ deps + if (!module.waitingMap[dep] && module.id !== dep) { + module.waiting++; + module.waitingMap[dep] = 1; + dependencyMap[dep] || (dependencyMap[dep] = {}); + dependencyMap[dep][module.id] = 1; + } + } + + function _initDependencies(id) { + var modulesToRequire = []; + var module = modulesMap[id]; + var dep, i, subdep; + + // initialize id's waitingMap + for (i = 0; i < module.dependencies.length; i++) { + dep = module.dependencies[i]; + if (!modulesMap[dep]) { + _addDependency(module, dep); + } else if (modulesMap[dep].waiting) { + for (subdep in modulesMap[dep].waitingMap) { + if (modulesMap[dep].waitingMap[subdep]) { + _addDependency(module, subdep); + } + } + } + } + if (module.waiting === 0 && module.special & REQUIRE_WHEN_READY) { + modulesToRequire.push(id); + } + + // update modules depending on id + if (dependencyMap[id]) { + var deps = dependencyMap[id]; + var submodule; + dependencyMap[id] = undefined; + for (dep in deps) { + submodule = modulesMap[dep]; + + // add all deps of id + for (subdep in module.waitingMap) { + if (module.waitingMap[subdep]) { + _addDependency(submodule, subdep); + } + } + // remove id itself + if (submodule.waitingMap[id]) { + submodule.waitingMap[id] = undefined; + submodule.waiting--; + } + if (submodule.waiting === 0 && + submodule.special & REQUIRE_WHEN_READY) { + modulesToRequire.push(dep); + } + } + } + + // run everything that's ready + for (i = 0; i < modulesToRequire.length; i++) { + require.call(null, modulesToRequire[i]); + } + } + + function _register(id, exports) { + var module = modulesMap[id] = { id: id }; + module.exports = exports; + module.refcount = 0; + } + + // pseudo name used in common-require + // see require() function for more info + _register('module', 0); + _register('exports', 0); + + _register('define', define); + _register('global', global); + _register('require', require); + _register('requireDynamic', require); + _register('requireLazy', requireLazy); + + define.amd = {}; + + global.define = define; + global.require = require; + global.requireDynamic = require; + global.requireLazy = requireLazy; + + require.__debug = { + modules: modulesMap, + deps: dependencyMap, + printDependencyInfo: function() { + if (!global.console) { + return; + } + var names = Object.keys(require.__debug.deps); + global.console.log(_debugUnresolvedDependencies(names)); + } + }; + + /** + * All @providesModule files are wrapped by this function by makehaste. It + * is a convenience function around define() that prepends a bunch of required + * modules (global, require, module, etc) so that we don't have to spit that + * out for every module which would be a lot of extra bytes. + */ + global.__d = function(id, deps, factory, _special, _inlineRequires) { + var defaultDeps = ['global', 'require', 'requireDynamic', 'requireLazy', + 'module', 'exports']; + define(id, defaultDeps.concat(deps), factory, _special || USED_AS_TRANSPORT, + null, null, _inlineRequires); + }; + +})(this); diff --git a/packager/react-packager/src/DependencyResolver/index.js b/packager/react-packager/src/DependencyResolver/index.js new file mode 100644 index 0000000000..79eb48c114 --- /dev/null +++ b/packager/react-packager/src/DependencyResolver/index.js @@ -0,0 +1,12 @@ +var HasteDependencyResolver = require('./haste'); +var NodeDependencyResolver = require('./node'); + +module.exports = function createDependencyResolver(options) { + if (options.moduleFormat === 'haste') { + return new HasteDependencyResolver(options); + } else if (options.moduleFormat === 'node') { + return new NodeDependencyResolver(options); + } else { + throw new Error('unsupported'); + } +}; diff --git a/packager/react-packager/src/DependencyResolver/node/index.js b/packager/react-packager/src/DependencyResolver/node/index.js new file mode 100644 index 0000000000..0d3b807ef7 --- /dev/null +++ b/packager/react-packager/src/DependencyResolver/node/index.js @@ -0,0 +1,48 @@ +var Promise = require('q').Promise; +var ModuleDescriptor = require('../ModuleDescriptor'); + +var mdeps = require('module-deps'); +var path = require('path'); +var fs = require('fs'); + +// var REQUIRE_RUNTIME = fs.readFileSync( +// path.join(__dirname, 'require.js') +// ).toString(); + +exports.getRuntimeCode = function() { + return REQUIRE_RUNTIME; +}; + +exports.wrapModule = function(id, source) { + return Promise.resolve( + 'define(' + JSON.stringify(id) + ',' + ' function(exports, module) {\n' + + source + '\n});' + ); +}; + +exports.getDependencies = function(root, fileEntryPath) { + return new Promise(function(resolve, reject) { + fileEntryPath = path.join(process.cwd(), root, fileEntryPath); + + var md = mdeps(); + + md.end({file: fileEntryPath}); + + var deps = []; + + md.on('data', function(data) { + deps.push( + new ModuleDescriptor({ + id: data.id, + deps: data.deps, + path: data.file, + entry: data.entry + }) + ); + }); + + md.on('end', function() { + resolve(deps); + }); + }); +}; diff --git a/packager/react-packager/src/FileWatcher/__tests__/FileWatcher-test.js b/packager/react-packager/src/FileWatcher/__tests__/FileWatcher-test.js new file mode 100644 index 0000000000..8baae9e114 --- /dev/null +++ b/packager/react-packager/src/FileWatcher/__tests__/FileWatcher-test.js @@ -0,0 +1,39 @@ +'use strict'; + +jest.dontMock('../') + .dontMock('q') + .setMock('child_process', { exec: function(cmd, cb) { cb(null, '/usr/bin/watchman') } }); + +describe('FileWatcher', function() { + var FileWatcher; + var Watcher; + + beforeEach(function() { + FileWatcher = require('../'); + Watcher = require('sane').WatchmanWatcher; + Watcher.prototype.once.mockImplementation(function(type, callback) { + callback(); + }); + }); + + it('it should get the watcher instance when ready', function() { + var fileWatcher = new FileWatcher(['rootDir']); + return fileWatcher._loading.then(function(watchers) { + watchers.forEach(function(watcher) { + expect(watcher instanceof Watcher).toBe(true); + }); + }); + }); + + pit('it should end the watcher', function() { + var fileWatcher = new FileWatcher(['rootDir']); + Watcher.prototype.close.mockImplementation(function(callback) { + callback(); + }); + + return fileWatcher.end().then(function() { + expect(Watcher.prototype.close).toBeCalled(); + }); + }); + +}); diff --git a/packager/react-packager/src/FileWatcher/index.js b/packager/react-packager/src/FileWatcher/index.js new file mode 100644 index 0000000000..f2721d8cfb --- /dev/null +++ b/packager/react-packager/src/FileWatcher/index.js @@ -0,0 +1,86 @@ +'use strict'; + +var EventEmitter = require('events').EventEmitter; +var sane = require('sane'); +var q = require('q'); +var util = require('util'); +var exec = require('child_process').exec; + +var Promise = q.Promise; + +var detectingWatcherClass = new Promise(function(resolve, reject) { + exec('which watchman', function(err, out) { + if (err || out.length === 0) { + resolve(sane.NodeWatcher); + } else { + resolve(sane.WatchmanWatcher); + } + }); +}); + +module.exports = FileWatcher; + +var MAX_WAIT_TIME = 3000; + +function FileWatcher(projectRoots) { + var self = this; + this._loading = q.all( + projectRoots.map(createWatcher) + ).then(function(watchers) { + watchers.forEach(function(watcher) { + watcher.on('all', function(type, filepath, root) { + self.emit('all', type, filepath, root); + }); + }); + return watchers; + }); + this._loading.done(); +} + +util.inherits(FileWatcher, EventEmitter); + +FileWatcher.prototype.end = function() { + return this._loading.then(function(watchers) { + watchers.forEach(function(watcher) { + delete watchersByRoot[watcher._root]; + return q.ninvoke(watcher, 'close'); + }); + }); +}; + +var watchersByRoot = Object.create(null); + +function createWatcher(root) { + if (watchersByRoot[root] != null) { + return Promise.resolve(watchersByRoot[root]); + } + + return detectingWatcherClass.then(function(Watcher) { + var watcher = new Watcher(root, {glob: '**/*.js'}); + + return new Promise(function(resolve, reject) { + var rejectTimeout = setTimeout(function() { + reject(new Error([ + 'Watcher took too long to load', + 'Try running `watchman` from your terminal', + 'https://facebook.github.io/watchman/docs/troubleshooting.html', + ].join('\n'))); + }, MAX_WAIT_TIME); + + watcher.once('ready', function() { + clearTimeout(rejectTimeout); + watchersByRoot[root] = watcher; + watcher._root = root; + resolve(watcher); + }); + }); + }); +} + +FileWatcher.createDummyWatcher = function() { + var ev = new EventEmitter(); + ev.end = function() { + return q(); + }; + return ev; +}; diff --git a/packager/react-packager/src/JSTransformer/Cache.js b/packager/react-packager/src/JSTransformer/Cache.js new file mode 100644 index 0000000000..577af696e4 --- /dev/null +++ b/packager/react-packager/src/JSTransformer/Cache.js @@ -0,0 +1,129 @@ +'use strict'; + +var path = require('path'); +var version = require('../../package.json').version; +var tmpdir = require('os').tmpDir(); +var pathUtils = require('../fb-path-utils'); +var fs = require('fs'); +var _ = require('underscore'); +var q = require('q'); + +var Promise = q.Promise; + +module.exports = Cache; + +function Cache(projectConfig) { + this._cacheFilePath = cacheFilePath(projectConfig); + + var data; + if (!projectConfig.resetCache) { + data = loadCacheSync(this._cacheFilePath); + } else { + data = Object.create(null); + } + this._data = data; + + this._has = Object.prototype.hasOwnProperty.bind(data); + this._persistEventually = _.debounce( + this._persistCache.bind(this), + 2000 + ); +} + +Cache.prototype.get = function(filepath, loaderCb) { + if (!pathUtils.isAbsolutePath(filepath)) { + throw new Error('Use absolute paths'); + } + + var recordP = this._has(filepath) + ? this._data[filepath] + : this._set(filepath, loaderCb(filepath)); + + return recordP.then(function(record) { + return record.data; + }); +}; + +Cache.prototype._set = function(filepath, loaderPromise) { + return this._data[filepath] = loaderPromise.then(function(data) { + return [ + data, + q.nfbind(fs.stat)(filepath) + ]; + }).spread(function(data, stat) { + this._persistEventually(); + return { + data: data, + mtime: stat.mtime.getTime(), + }; + }.bind(this)); +}; + +Cache.prototype.invalidate = function(filepath){ + if(this._has(filepath)) { + delete this._data[filepath]; + } +} + +Cache.prototype.end = function() { + return this._persistCache(); +}; + +Cache.prototype._persistCache = function() { + if (this._persisting != null) { + return this._persisting; + } + + var data = this._data; + var cacheFilepath = this._cacheFilePath; + + return this._persisting = q.all(_.values(data)) + .then(function(values) { + var json = Object.create(null); + Object.keys(data).forEach(function(key, i) { + json[key] = values[i]; + }); + return q.nfbind(fs.writeFile)(cacheFilepath, JSON.stringify(json)); + }) + .then(function() { + this._persisting = null; + return true; + }.bind(this)); +}; + +function loadCacheSync(cacheFilepath) { + var ret = Object.create(null); + if (!fs.existsSync(cacheFilepath)) { + return ret; + } + + var cacheOnDisk = JSON.parse(fs.readFileSync(cacheFilepath)); + + // Filter outdated cache and convert to promises. + Object.keys(cacheOnDisk).forEach(function(key) { + if (!fs.existsSync(key)) { + return; + } + var value = cacheOnDisk[key]; + var stat = fs.statSync(key); + if (stat.mtime.getTime() === value.mtime) { + ret[key] = Promise.resolve(value); + } + }); + + return ret; +} + +function cacheFilePath(projectConfig) { + var roots = projectConfig.projectRoots.join(',').split(path.sep).join('-'); + var cacheVersion = projectConfig.cacheVersion || '0'; + return path.join( + tmpdir, + [ + 'react-packager-cache', + version, + cacheVersion, + roots, + ].join('-') + ); +} diff --git a/packager/react-packager/src/JSTransformer/README.md b/packager/react-packager/src/JSTransformer/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packager/react-packager/src/JSTransformer/__mocks__/worker.js b/packager/react-packager/src/JSTransformer/__mocks__/worker.js new file mode 100644 index 0000000000..04a24e8db4 --- /dev/null +++ b/packager/react-packager/src/JSTransformer/__mocks__/worker.js @@ -0,0 +1,5 @@ +'use strict'; + +module.exports = function (data, callback) { + callback(null, {}); +}; diff --git a/packager/react-packager/src/JSTransformer/__tests__/Cache-test.js b/packager/react-packager/src/JSTransformer/__tests__/Cache-test.js new file mode 100644 index 0000000000..c77c638432 --- /dev/null +++ b/packager/react-packager/src/JSTransformer/__tests__/Cache-test.js @@ -0,0 +1,202 @@ +'use strict'; + +jest + .dontMock('underscore') + .dontMock('path') + .dontMock('q') + .dontMock('absolute-path') + .dontMock('../../fb-path-utils') + .dontMock('../Cache'); + +var q = require('q'); + +describe('JSTransformer Cache', function() { + var Cache; + + beforeEach(function() { + require('os').tmpDir.mockImpl(function() { + return 'tmpDir'; + }); + + Cache = require('../Cache'); + }); + + describe('getting/settig', function() { + it('calls loader callback for uncached file', function() { + var cache = new Cache({projectRoots: ['/rootDir']}); + var loaderCb = jest.genMockFn().mockImpl(function() { + return q(); + }); + cache.get('/rootDir/someFile', loaderCb); + expect(loaderCb).toBeCalledWith('/rootDir/someFile'); + }); + + pit('gets the value from the loader callback', function() { + require('fs').stat.mockImpl(function(file, callback) { + callback(null, { + mtime: { + getTime: function() {} + } + }); + }); + var cache = new Cache({projectRoots: ['/rootDir']}); + var loaderCb = jest.genMockFn().mockImpl(function() { + return q('lol'); + }); + return cache.get('/rootDir/someFile', loaderCb).then(function(value) { + expect(value).toBe('lol'); + }); + }); + + pit('caches the value after the first call', function() { + require('fs').stat.mockImpl(function(file, callback) { + callback(null, { + mtime: { + getTime: function() {} + } + }); + }); + var cache = new Cache({projectRoots: ['/rootDir']}); + var loaderCb = jest.genMockFn().mockImpl(function() { + return q('lol'); + }); + return cache.get('/rootDir/someFile', loaderCb).then(function() { + var shouldNotBeCalled = jest.genMockFn(); + return cache.get('/rootDir/someFile', shouldNotBeCalled) + .then(function(value) { + expect(shouldNotBeCalled).not.toBeCalled(); + expect(value).toBe('lol'); + }); + }); + }); + }); + + describe('loading cache from disk', function() { + var fileStats; + + beforeEach(function() { + fileStats = { + '/rootDir/someFile': { + mtime: { + getTime: function() { + return 22; + } + } + }, + '/rootDir/foo': { + mtime: { + getTime: function() { + return 11; + } + } + } + }; + + var fs = require('fs'); + + fs.existsSync.mockImpl(function() { + return true; + }); + + fs.statSync.mockImpl(function(filePath) { + return fileStats[filePath]; + }); + + fs.readFileSync.mockImpl(function() { + return JSON.stringify({ + '/rootDir/someFile': { + mtime: 22, + data: 'oh hai' + }, + '/rootDir/foo': { + mtime: 11, + data: 'lol wat' + } + }); + }); + }); + + pit('should load cache from disk', function() { + var cache = new Cache({projectRoots: ['/rootDir']}); + var loaderCb = jest.genMockFn(); + return cache.get('/rootDir/someFile', loaderCb).then(function(value) { + expect(loaderCb).not.toBeCalled(); + expect(value).toBe('oh hai'); + + return cache.get('/rootDir/foo', loaderCb).then(function(value) { + expect(loaderCb).not.toBeCalled(); + expect(value).toBe('lol wat'); + }); + }); + }); + + pit('should not load outdated cache', function() { + require('fs').stat.mockImpl(function(file, callback) { + callback(null, { + mtime: { + getTime: function() {} + } + }); + }); + + fileStats['/rootDir/foo'].mtime.getTime = function() { + return 123; + }; + + var cache = new Cache({projectRoots: ['/rootDir']}); + var loaderCb = jest.genMockFn().mockImpl(function() { + return q('new value'); + }); + + return cache.get('/rootDir/someFile', loaderCb).then(function(value) { + expect(loaderCb).not.toBeCalled(); + expect(value).toBe('oh hai'); + + return cache.get('/rootDir/foo', loaderCb).then(function(value) { + expect(loaderCb).toBeCalled(); + expect(value).toBe('new value'); + }); + }); + }); + }); + + describe('writing cache to disk', function() { + it('should write cache to disk', function() { + var index = 0; + var mtimes = [10, 20, 30]; + var debounceIndex = 0; + require('underscore').debounce = function(callback) { + return function () { + if (++debounceIndex === 3) { + callback(); + } + }; + }; + + var fs = require('fs'); + fs.stat.mockImpl(function(file, callback) { + callback(null, { + mtime: { + getTime: function() { + return mtimes[index++]; + } + } + }); + }); + + var cache = new Cache({projectRoots: ['/rootDir']}); + cache.get('/rootDir/bar', function() { + return q('bar value'); + }); + cache.get('/rootDir/foo', function() { + return q('foo value'); + }); + cache.get('/rootDir/baz', function() { + return q('baz value'); + }); + + jest.runAllTimers(); + expect(fs.writeFile).toBeCalled(); + }); + }); +}); diff --git a/packager/react-packager/src/JSTransformer/__tests__/Transformer-test.js b/packager/react-packager/src/JSTransformer/__tests__/Transformer-test.js new file mode 100644 index 0000000000..6c9c66446f --- /dev/null +++ b/packager/react-packager/src/JSTransformer/__tests__/Transformer-test.js @@ -0,0 +1,71 @@ +'use strict'; + +jest + .dontMock('worker-farm') + .dontMock('q') + .dontMock('os') + .dontMock('../index'); + +var OPTIONS = { + transformModulePath: '/foo/bar' +}; + +describe('Transformer', function() { + var Transformer; + var workers; + + beforeEach(function() { + workers = jest.genMockFn(); + jest.setMock('worker-farm', jest.genMockFn().mockImpl(function() { + return workers; + })); + require('../Cache').prototype.get.mockImpl(function(filePath, callback) { + return callback(); + }); + require('fs').readFile.mockImpl(function(file, callback) { + callback(null, 'content'); + }); + Transformer = require('../'); + }); + + pit('should loadFileAndTransform', function() { + workers.mockImpl(function(data, callback) { + callback(null, { code: 'transformed' }); + }); + require('fs').readFile.mockImpl(function(file, callback) { + callback(null, 'content'); + }); + + return new Transformer(OPTIONS).loadFileAndTransform([], 'file', {}) + .then(function(data) { + expect(data).toEqual({ + code: 'transformed', + sourcePath: 'file', + sourceCode: 'content' + }); + }); + }); + + pit('should add file info to parse errors', function() { + require('fs').readFile.mockImpl(function(file, callback) { + callback(null, 'var x;\nvar answer = 1 = x;'); + }); + + workers.mockImpl(function(data, callback) { + var esprimaError = new Error('Error: Line 2: Invalid left-hand side in assignment'); + esprimaError.description = 'Invalid left-hand side in assignment'; + esprimaError.lineNumber = 2; + esprimaError.column = 15; + callback(null, {error: esprimaError}); + }); + + return new Transformer(OPTIONS).loadFileAndTransform([], 'foo-file.js', {}) + .catch(function(error) { + expect(error.type).toEqual('TransformError'); + expect(error.snippet).toEqual([ + 'var answer = 1 = x;', + ' ^', + ].join('\n')); + }); + }); +}); diff --git a/packager/react-packager/src/JSTransformer/index.js b/packager/react-packager/src/JSTransformer/index.js new file mode 100644 index 0000000000..7b01d9617d --- /dev/null +++ b/packager/react-packager/src/JSTransformer/index.js @@ -0,0 +1,112 @@ + +'use strict'; + +var os = require('os'); +var fs = require('fs'); +var q = require('q'); +var Cache = require('./Cache'); +var _ = require('underscore'); +var workerFarm = require('worker-farm'); + +var readFile = q.nfbind(fs.readFile); + +module.exports = Transformer; +Transformer.TransformError = TransformError; + +function Transformer(projectConfig) { + this._cache = projectConfig.nonPersistent + ? new DummyCache() : new Cache(projectConfig); + + if (projectConfig.transformModulePath == null) { + this._failedToStart = q.Promise.reject(new Error('No transfrom module')); + } else { + this._workers = workerFarm( + {autoStart: true}, + projectConfig.transformModulePath + ); + } +} + +Transformer.prototype.kill = function() { + this._workers && workerFarm.end(this._workers); + return this._cache.end(); +}; + +Transformer.prototype.invalidateFile = function(filePath) { + this._cache.invalidate(filePath); + //TODO: We can read the file and put it into the cache right here + // This would simplify some caching logic as we can be sure that the cache is up to date +} + +Transformer.prototype.loadFileAndTransform = function( + transformSets, + filePath, + options +) { + if (this._failedToStart) { + return this._failedToStart; + } + + var workers = this._workers; + return this._cache.get(filePath, function() { + return readFile(filePath) + .then(function(buffer) { + var sourceCode = buffer.toString(); + var opts = _.extend({}, options, {filename: filePath}); + return q.nfbind(workers)({ + transformSets: transformSets, + sourceCode: sourceCode, + options: opts, + }).then( + function(res) { + if (res.error) { + throw formatEsprimaError(res.error, filePath, sourceCode); + } + + return { + code: res.code, + sourcePath: filePath, + sourceCode: sourceCode + }; + } + ); + }); + }); +}; + +function TransformError() {} +TransformError.__proto__ = SyntaxError.prototype; + +function formatEsprimaError(err, filename, source) { + if (!(err.lineNumber && err.column)) { + return err; + } + + var stack = err.stack.split('\n'); + stack.shift(); + + var msg = 'TransformError: ' + err.description + ' ' + filename + ':' + + err.lineNumber + ':' + err.column; + var sourceLine = source.split('\n')[err.lineNumber - 1]; + var snippet = sourceLine + '\n' + new Array(err.column - 1).join(' ') + '^'; + + stack.unshift(msg); + + var error = new TransformError(); + error.message = msg; + error.type = 'TransformError'; + error.stack = stack.join('\n'); + error.snippet = snippet; + error.filename = filename; + error.lineNumber = err.lineNumber; + error.column = err.column; + error.description = err.description; + return error; +} + +function DummyCache() {} +DummyCache.prototype.get = function(filePath, loaderCb) { + return loaderCb(); +}; +DummyCache.prototype.end = +DummyCache.prototype.invalidate = function(){}; diff --git a/packager/react-packager/src/JSTransformer/worker.js b/packager/react-packager/src/JSTransformer/worker.js new file mode 100644 index 0000000000..26f789e404 --- /dev/null +++ b/packager/react-packager/src/JSTransformer/worker.js @@ -0,0 +1,26 @@ +'use strict'; + +var transformer = require('./transformer'); + +module.exports = function (data, callback) { + var result; + try { + result = transformer.transform( + data.transformSets, + data.sourceCode, + data.options + ); + } catch (e) { + return callback(null, { + error: { + lineNumber: e.lineNumber, + column: e.column, + message: e.message, + stack: e.stack, + description: e.description + } + }); + } + + callback(null, result); +}; diff --git a/packager/react-packager/src/Packager/Package.js b/packager/react-packager/src/Packager/Package.js new file mode 100644 index 0000000000..787684bc25 --- /dev/null +++ b/packager/react-packager/src/Packager/Package.js @@ -0,0 +1,132 @@ +'use strict'; + +var _ = require('underscore'); +var SourceMapGenerator = require('source-map').SourceMapGenerator; +var base64VLQ = require('./base64-vlq'); + +module.exports = Package; + +function Package(sourceMapUrl) { + this._modules = []; + this._sourceMapUrl = sourceMapUrl; +} + +Package.prototype.setMainModuleId = function(moduleId) { + this._mainModuleId = moduleId; +}; + +Package.prototype.addModule = function( + transformedCode, + sourceCode, + sourcePath +) { + this._modules.push({ + transformedCode: transformedCode, + sourceCode: sourceCode, + sourcePath: sourcePath + }); +}; + +Package.prototype.finalize = function(options) { + if (options.runMainModule) { + var runCode = ';require("' + this._mainModuleId + '");'; + this.addModule( + runCode, + runCode, + 'RunMainModule.js' + ); + } + + Object.freeze(this._modules); + Object.seal(this._modules); +}; + +Package.prototype.getSource = function() { + return this._source || ( + this._source = _.pluck(this._modules, 'transformedCode').join('\n') + '\n' + + 'RAW_SOURCE_MAP = ' + JSON.stringify(this.getSourceMap({excludeSource: true})) + + ';\n' + '\/\/@ sourceMappingURL=' + this._sourceMapUrl + ); +}; + +Package.prototype.getSourceMap = function(options) { + options = options || {}; + var mappings = this._getMappings(); + var map = { + file: 'bundle.js', + sources: _.pluck(this._modules, 'sourcePath'), + version: 3, + names: [], + mappings: mappings, + sourcesContent: options.excludeSource + ? [] : _.pluck(this._modules, 'sourceCode') + }; + return map; +}; + + +Package.prototype._getMappings = function() { + var modules = this._modules; + + // The first line mapping in our package is basically the base64vlq code for + // zeros (A). + var firstLine = 'AAAA'; + + // Most other lines in our mappings are all zeros (for module, column etc) + // except for the lineno mappinp: curLineno - prevLineno = 1; Which is C. + var line = 'AACA'; + + var mappings = ''; + for (var i = 0; i < modules.length; i++) { + var module = modules[i]; + var transformedCode = module.transformedCode; + var lastCharNewLine = false; + module.lines = 0; + for (var t = 0; t < transformedCode.length; t++) { + if (t === 0 && i === 0) { + mappings += firstLine; + } else if (t === 0) { + mappings += 'AC'; + + // This is the only place were we actually don't know the mapping ahead + // of time. When it's a new module (and not the first) the lineno + // mapping is 0 (current) - number of lines in prev module. + mappings += base64VLQ.encode(0 - modules[i - 1].lines); + mappings += 'A'; + } else if (lastCharNewLine) { + module.lines++; + mappings += line; + } + lastCharNewLine = transformedCode[t] === '\n'; + if (lastCharNewLine) { + mappings += ';'; + } + } + if (i != modules.length - 1) { + mappings += ';'; + } + } + return mappings; +}; + +Package.prototype.getDebugInfo = function() { + return [ + '

Main Module:

' + this._mainModuleId + '
', + '', + '

Module paths and transformed code:

', + this._modules.map(function(m) { + return '

Path:

' + m.sourcePath + '

Source:

' + + '
'; + }).join('\n'), + ].join('\n'); +}; diff --git a/packager/react-packager/src/Packager/__mocks__/source-map.js b/packager/react-packager/src/Packager/__mocks__/source-map.js new file mode 100644 index 0000000000..08c127f6d3 --- /dev/null +++ b/packager/react-packager/src/Packager/__mocks__/source-map.js @@ -0,0 +1,5 @@ +var SourceMapGenerator = jest.genMockFn(); +SourceMapGenerator.prototype.addMapping = jest.genMockFn(); +SourceMapGenerator.prototype.setSourceContent = jest.genMockFn(); +SourceMapGenerator.prototype.toJSON = jest.genMockFn(); +exports.SourceMapGenerator = SourceMapGenerator; diff --git a/packager/react-packager/src/Packager/__tests__/Package-test.js b/packager/react-packager/src/Packager/__tests__/Package-test.js new file mode 100644 index 0000000000..d18bb4d6c1 --- /dev/null +++ b/packager/react-packager/src/Packager/__tests__/Package-test.js @@ -0,0 +1,95 @@ +'use strict'; + +jest + .dontMock('underscore') + .dontMock('../base64-vlq') + .dontMock('source-map') + .dontMock('../Package'); + +var SourceMapGenerator = require('source-map').SourceMapGenerator; + +describe('Package', function() { + var Package; + var ppackage; + + beforeEach(function() { + Package = require('../Package'); + ppackage = new Package('test_url'); + ppackage.getSourceMap = jest.genMockFn().mockImpl(function() { + return 'test-source-map'; + }); + }); + + describe('source package', function() { + it('should create a package and get the source', function() { + ppackage.addModule('transformed foo;', 'source foo', 'foo path'); + ppackage.addModule('transformed bar;', 'source bar', 'bar path'); + ppackage.finalize({}); + expect(ppackage.getSource()).toBe([ + 'transformed foo;', + 'transformed bar;', + 'RAW_SOURCE_MAP = "test-source-map";', + '\/\/@ sourceMappingURL=test_url', + ].join('\n')); + }); + + it('should create a package and add run module code', function() { + ppackage.addModule('transformed foo;', 'source foo', 'foo path'); + ppackage.addModule('transformed bar;', 'source bar', 'bar path'); + ppackage.setMainModuleId('foo'); + ppackage.finalize({runMainModule: true}); + expect(ppackage.getSource()).toBe([ + 'transformed foo;', + 'transformed bar;', + ';require("foo");', + 'RAW_SOURCE_MAP = "test-source-map";', + '\/\/@ sourceMappingURL=test_url', + ].join('\n')); + }); + }); + + describe('sourcemap package', function() { + it('should create sourcemap', function() { + var ppackage = new Package('test_url'); + ppackage.addModule('transformed foo;\n', 'source foo', 'foo path'); + ppackage.addModule('transformed bar;\n', 'source bar', 'bar path'); + ppackage.setMainModuleId('foo'); + ppackage.finalize({runMainModule: true}); + var s = ppackage.getSourceMap(); + expect(s).toEqual(genSourceMap(ppackage._modules)); + }); + }); +}); + + function genSourceMap(modules) { + var sourceMapGen = new SourceMapGenerator({file: 'bundle.js', version: 3}); + var packageLineNo = 0; + for (var i = 0; i < modules.length; i++) { + var module = modules[i]; + var transformedCode = module.transformedCode; + var sourcePath = module.sourcePath; + var sourceCode = module.sourceCode; + var transformedLineCount = 0; + var lastCharNewLine = false; + for (var t = 0; t < transformedCode.length; t++) { + if (t === 0 || lastCharNewLine) { + sourceMapGen.addMapping({ + generated: {line: packageLineNo + 1, column: 0}, + original: {line: transformedLineCount + 1, column: 0}, + source: sourcePath + }); + } + lastCharNewLine = transformedCode[t] === '\n'; + if (lastCharNewLine) { + transformedLineCount++; + packageLineNo++; + } + } + packageLineNo++; + sourceMapGen.setSourceContent( + sourcePath, + sourceCode + ); + } + return sourceMapGen.toJSON(); +}; diff --git a/packager/react-packager/src/Packager/__tests__/Packager-test.js b/packager/react-packager/src/Packager/__tests__/Packager-test.js new file mode 100644 index 0000000000..21af12ca82 --- /dev/null +++ b/packager/react-packager/src/Packager/__tests__/Packager-test.js @@ -0,0 +1,83 @@ +'use strict'; + +jest + .setMock('worker-farm', function() { return function() {};}) + .dontMock('path') + .dontMock('q') + .dontMock('os') + .dontMock('underscore') + .dontMock('../'); + +var q = require('q'); + +describe('Packager', function() { + var getDependencies; + var wrapModule; + var Packager; + + beforeEach(function() { + getDependencies = jest.genMockFn(); + wrapModule = jest.genMockFn(); + require('../../DependencyResolver').mockImpl(function() { + return { + getDependencies: getDependencies, + wrapModule: wrapModule, + }; + }); + + Packager = require('../'); + }); + + pit('create a package', function() { + require('fs').statSync.mockImpl(function() { + return { + isDirectory: function() {return true;} + }; + }); + + var packager = new Packager({projectRoots: []}); + var modules = [ + {id: 'foo', path: '/root/foo.js', dependencies: []}, + {id: 'bar', path: '/root/bar.js', dependencies: []}, + ]; + + getDependencies.mockImpl(function() { + return q({ + mainModuleId: 'foo', + dependencies: modules + }); + }); + + require('../../JSTransformer').prototype.loadFileAndTransform + .mockImpl(function(tsets, path) { + return q({ + code: 'transformed ' + path, + sourceCode: 'source ' + path, + sourcePath: path + }); + }); + + wrapModule.mockImpl(function(module, code) { + return 'lol ' + code + ' lol'; + }); + + return packager.package('/root/foo.js', true, 'source_map_url') + .then(function(p) { + expect(p.addModule.mock.calls[0]).toEqual([ + 'lol transformed /root/foo.js lol', + 'source /root/foo.js', + '/root/foo.js' + ]); + expect(p.addModule.mock.calls[1]).toEqual([ + 'lol transformed /root/bar.js lol', + 'source /root/bar.js', + '/root/bar.js' + ]); + + expect(p.finalize.mock.calls[0]).toEqual([ + {runMainModule: true} + ]); + }); + }); + +}); diff --git a/packager/react-packager/src/Packager/base64-vlq.js b/packager/react-packager/src/Packager/base64-vlq.js new file mode 100644 index 0000000000..91d490b7d5 --- /dev/null +++ b/packager/react-packager/src/Packager/base64-vlq.js @@ -0,0 +1,168 @@ +/* -*- Mode: js; js-indent-level: 2; -*- */ +/* + * Copyright 2011 Mozilla Foundation and contributors + * Licensed under the New BSD license. See LICENSE or: + * http://opensource.org/licenses/BSD-3-Clause + * + * Based on the Base 64 VLQ implementation in Closure Compiler: + * https://code.google.com/p/closure-compiler/source/browse/trunk/src/com/google/debugging/sourcemap/Base64VLQ.java + * + * Copyright 2011 The Closure Compiler Authors. All rights reserved. + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following + * disclaimer in the documentation and/or other materials provided + * with the distribution. + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived + * from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +var charToIntMap = {}; +var intToCharMap = {}; + +'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + .split('') + .forEach(function (ch, index) { + charToIntMap[ch] = index; + intToCharMap[index] = ch; + }); + +var base64 = {}; +/** + * Encode an integer in the range of 0 to 63 to a single base 64 digit. + */ +base64.encode = function base64_encode(aNumber) { + if (aNumber in intToCharMap) { + return intToCharMap[aNumber]; + } + throw new TypeError("Must be between 0 and 63: " + aNumber); +}; + +/** + * Decode a single base 64 digit to an integer. + */ +base64.decode = function base64_decode(aChar) { + if (aChar in charToIntMap) { + return charToIntMap[aChar]; + } + throw new TypeError("Not a valid base 64 digit: " + aChar); +}; + + + +// A single base 64 digit can contain 6 bits of data. For the base 64 variable +// length quantities we use in the source map spec, the first bit is the sign, +// the next four bits are the actual value, and the 6th bit is the +// continuation bit. The continuation bit tells us whether there are more +// digits in this value following this digit. +// +// Continuation +// | Sign +// | | +// V V +// 101011 + +var VLQ_BASE_SHIFT = 5; + +// binary: 100000 +var VLQ_BASE = 1 << VLQ_BASE_SHIFT; + +// binary: 011111 +var VLQ_BASE_MASK = VLQ_BASE - 1; + +// binary: 100000 +var VLQ_CONTINUATION_BIT = VLQ_BASE; + +/** + * Converts from a two-complement value to a value where the sign bit is + * placed in the least significant bit. For example, as decimals: + * 1 becomes 2 (10 binary), -1 becomes 3 (11 binary) + * 2 becomes 4 (100 binary), -2 becomes 5 (101 binary) + */ +function toVLQSigned(aValue) { + return aValue < 0 + ? ((-aValue) << 1) + 1 + : (aValue << 1) + 0; +} + +/** + * Converts to a two-complement value from a value where the sign bit is + * placed in the least significant bit. For example, as decimals: + * 2 (10 binary) becomes 1, 3 (11 binary) becomes -1 + * 4 (100 binary) becomes 2, 5 (101 binary) becomes -2 + */ +function fromVLQSigned(aValue) { + var isNegative = (aValue & 1) === 1; + var shifted = aValue >> 1; + return isNegative + ? -shifted + : shifted; +} + +/** + * Returns the base 64 VLQ encoded value. + */ +exports.encode = function base64VLQ_encode(aValue) { + var encoded = ""; + var digit; + + var vlq = toVLQSigned(aValue); + + do { + digit = vlq & VLQ_BASE_MASK; + vlq >>>= VLQ_BASE_SHIFT; + if (vlq > 0) { + // There are still more digits in this value, so we must make sure the + // continuation bit is marked. + digit |= VLQ_CONTINUATION_BIT; + } + encoded += base64.encode(digit); + } while (vlq > 0); + + return encoded; +}; + +/** + * Decodes the next base 64 VLQ value from the given string and returns the + * value and the rest of the string via the out parameter. + */ +exports.decode = function base64VLQ_decode(aStr, aOutParam) { + var i = 0; + var strLen = aStr.length; + var result = 0; + var shift = 0; + var continuation, digit; + + do { + if (i >= strLen) { + throw new Error("Expected more digits in base 64 VLQ value."); + } + digit = base64.decode(aStr.charAt(i++)); + continuation = !!(digit & VLQ_CONTINUATION_BIT); + digit &= VLQ_BASE_MASK; + result = result + (digit << shift); + shift += VLQ_BASE_SHIFT; + } while (continuation); + + aOutParam.value = fromVLQSigned(result); + aOutParam.rest = aStr.slice(i); +}; + diff --git a/packager/react-packager/src/Packager/index.js b/packager/react-packager/src/Packager/index.js new file mode 100644 index 0000000000..3ec4e378c0 --- /dev/null +++ b/packager/react-packager/src/Packager/index.js @@ -0,0 +1,127 @@ +'use strict'; + +var assert = require('assert'); +var fs = require('fs'); +var path = require('path'); +var q = require('q'); +var Promise = require('q').Promise; +var Transformer = require('../JSTransformer'); +var DependencyResolver = require('../DependencyResolver'); +var _ = require('underscore'); +var Package = require('./Package'); +var Activity = require('../Activity'); + +var DEFAULT_CONFIG = { + /** + * RegExp used to ignore paths when scanning the filesystem to calculate the + * dependency graph. + */ + blacklistRE: null, + + /** + * The kind of module system/transport wrapper to use for the modules bundled + * in the package. + */ + moduleFormat: 'haste', + + /** + * An ordered list of module names that should be considered as dependencies + * of every module in the system. The list is ordered because every item in + * the list will have an implicit dependency on all items before it. + * + * (This ordering is necessary to build, for example, polyfills that build on + * each other) + */ + polyfillModuleNames: [], + + nonPersistent: false, +}; + +function Packager(projectConfig) { + projectConfig.projectRoots.forEach(verifyRootExists); + + this._config = Object.create(DEFAULT_CONFIG); + for (var key in projectConfig) { + this._config[key] = projectConfig[key]; + } + + this._resolver = new DependencyResolver(this._config); + + this._transformer = new Transformer(projectConfig); +} + +Packager.prototype.kill = function() { + return q.all([ + this._transformer.kill(), + this._resolver.end(), + ]); +}; + +Packager.prototype.package = function(main, runModule, sourceMapUrl) { + var transformModule = this._transformModule.bind(this); + var ppackage = new Package(sourceMapUrl); + + var findEventId = Activity.startEvent('find dependencies'); + var transformEventId; + + return this.getDependencies(main) + .then(function(result) { + Activity.endEvent(findEventId); + transformEventId = Activity.startEvent('transform'); + + ppackage.setMainModuleId(result.mainModuleId); + return Promise.all( + result.dependencies.map(transformModule) + ); + }) + .then(function(transformedModules) { + Activity.endEvent(transformEventId); + + transformedModules.forEach(function(transformed) { + ppackage.addModule( + transformed.code, + transformed.sourceCode, + transformed.sourcePath + ); + }); + + ppackage.finalize({ runMainModule: runModule }); + return ppackage; + }); +}; + +Packager.prototype.invalidateFile = function(filePath) { + this._transformer.invalidateFile(filePath); +} + +Packager.prototype.getDependencies = function(main) { + return this._resolver.getDependencies(main); +}; + +Packager.prototype._transformModule = function(module) { + var resolver = this._resolver; + return this._transformer.loadFileAndTransform( + ['es6'], + path.resolve(module.path), + this._config.transformer || {} + ).then(function(transformed) { + return _.extend( + {}, + transformed, + {code: resolver.wrapModule(module, transformed.code)} + ); + }); +}; + + +function verifyRootExists(root) { + // Verify that the root exists. + assert(fs.statSync(root).isDirectory(), 'Root has to be a valid directory'); +} + +Packager.prototype.getGraphDebugInfo = function() { + return this._resolver.getDebugInfo(); +}; + + +module.exports = Packager; diff --git a/packager/react-packager/src/Server/__tests__/Server-test.js b/packager/react-packager/src/Server/__tests__/Server-test.js new file mode 100644 index 0000000000..511ec8a363 --- /dev/null +++ b/packager/react-packager/src/Server/__tests__/Server-test.js @@ -0,0 +1,158 @@ +jest.setMock('worker-farm', function(){ return function(){}; }) + .dontMock('q') + .dontMock('os') + .dontMock('errno/custom') + .dontMock('path') + .dontMock('url') + .dontMock('../'); + + +var server = require('../'); +var q = require('q'); + +describe('processRequest', function(){ + var server; + var Activity; + var Packager; + var FileWatcher; + + var options = { + projectRoots: ['root'], + blacklistRE: null, + cacheVersion: null, + polyfillModuleNames: null + }; + + var makeRequest = function(requestHandler, requrl){ + var deferred = q.defer(); + requestHandler({ + url: requrl + },{ + end: function(res){ + deferred.resolve(res); + } + },{ + next: function(){} + } + ); + return deferred.promise; + }; + + var invalidatorFunc = jest.genMockFunction(); + var watcherFunc = jest.genMockFunction(); + var requestHandler; + + beforeEach(function(){ + Activity = require('../../Activity'); + Packager = require('../../Packager'); + FileWatcher = require('../../FileWatcher') + + Packager.prototype.package = function(main, runModule, sourceMapUrl) { + return q({ + getSource: function(){ + return "this is the source" + }, + getSourceMap: function(){ + return "this is the source map" + } + }) + }; + + FileWatcher.prototype.on = watcherFunc; + + Packager.prototype.invalidateFile = invalidatorFunc; + + var Server = require('../'); + server = new Server(options); + requestHandler = server.processRequest.bind(server); + }); + + pit('returns JS bundle source on request of *.bundle',function(){ + result = makeRequest(requestHandler,'mybundle.includeRequire.runModule.bundle'); + return result.then(function(response){ + expect(response).toEqual("this is the source"); + }); + }); + + pit('returns sourcemap on request of *.map', function(){ + result = makeRequest(requestHandler,'mybundle.includeRequire.runModule.bundle.map'); + return result.then(function(response){ + expect(response).toEqual('"this is the source map"'); + }); + }); + + pit('watches all files in projectRoot', function(){ + result = makeRequest(requestHandler,'mybundle.includeRequire.runModule.bundle'); + return result.then(function(response){ + expect(watcherFunc.mock.calls[0][0]).toEqual('all'); + expect(watcherFunc.mock.calls[0][1]).not.toBe(null); + }) + }); + + + describe('file changes', function() { + var triggerFileChange; + beforeEach(function() { + FileWatcher.prototype.on = function(eventType, callback) { + if (eventType !== 'all') { + throw new Error('Can only handle "all" event in watcher.'); + } + triggerFileChange = callback; + return this; + }; + }); + + pit('invalides files in package when file is updated', function() { + result = makeRequest(requestHandler,'mybundle.includeRequire.runModule.bundle'); + return result.then(function(response){ + var onFileChange = watcherFunc.mock.calls[0][1]; + onFileChange('all','path/file.js', options.projectRoots[0]); + expect(invalidatorFunc.mock.calls[0][0]).toEqual('root/path/file.js'); + }); + }); + + pit('rebuilds the packages that contain a file when that file is changed', function() { + var packageFunc = jest.genMockFunction(); + packageFunc + .mockReturnValueOnce( + q({ + getSource: function(){ + return "this is the first source" + }, + getSourceMap: function(){}, + }) + ) + .mockReturnValue( + q({ + getSource: function(){ + return "this is the rebuilt source" + }, + getSourceMap: function(){}, + }) + ); + + Packager.prototype.package = packageFunc; + + var Server = require('../../Server'); + var server = new Server(options); + + requestHandler = server.processRequest.bind(server); + + + return makeRequest(requestHandler,'mybundle.includeRequire.runModule.bundle') + .then(function(response){ + expect(response).toEqual("this is the first source"); + expect(packageFunc.mock.calls.length).toBe(1); + triggerFileChange('all','path/file.js', options.projectRoots[0]); + jest.runAllTimers(); + }) + .then(function(){ + expect(packageFunc.mock.calls.length).toBe(2); + return makeRequest(requestHandler,'mybundle.includeRequire.runModule.bundle') + .then(function(response){ + expect(response).toEqual("this is the rebuilt source"); + }); + }); + }); + }); +}); diff --git a/packager/react-packager/src/Server/index.js b/packager/react-packager/src/Server/index.js new file mode 100644 index 0000000000..26929ebb57 --- /dev/null +++ b/packager/react-packager/src/Server/index.js @@ -0,0 +1,173 @@ +var url = require('url'); +var path = require('path'); +var FileWatcher = require('../FileWatcher') +var Packager = require('../Packager'); +var Activity = require('../Activity'); +var q = require('q'); + +module.exports = Server; + +function Server(options) { + this._projectRoots = options.projectRoots; + this._packages = Object.create(null); + this._packager = new Packager({ + projectRoots: options.projectRoots, + blacklistRE: options.blacklistRE, + polyfillModuleNames: options.polyfillModuleNames || [], + runtimeCode: options.runtimeCode, + cacheVersion: options.cacheVersion, + resetCache: options.resetCache, + dev: options.dev, + transformModulePath: options.transformModulePath, + nonPersistent: options.nonPersistent, + }); + + this._fileWatcher = options.nonPersistent + ? FileWatcher.createDummyWatcher() + : new FileWatcher(options.projectRoots); + + var onFileChange = this._onFileChange.bind(this); + this._fileWatcher.on('all', onFileChange); +} + +Server.prototype._onFileChange = function(type, filepath, root) { + var absPath = path.join(root, filepath); + this._packager.invalidateFile(absPath); + // Make sure the file watcher event runs through the system before + // we rebuild the packages. + setImmediate(this._rebuildPackages.bind(this, absPath)) +}; + +Server.prototype._rebuildPackages = function(filepath) { + var buildPackage = this._buildPackage.bind(this); + var packages = this._packages; + Object.keys(packages).forEach(function(key) { + var options = getOptionsFromPath(url.parse(key).pathname); + packages[key] = buildPackage(options).then(function(p) { + // Make a throwaway call to getSource to cache the source string. + p.getSource(); + return p; + }); + }); +}; + +Server.prototype.end = function() { + q.all([ + this._fileWatcher.end(), + this._packager.kill(), + ]); +}; + +Server.prototype._buildPackage = function(options) { + return this._packager.package( + options.main, + options.runModule, + options.sourceMapUrl + ); +}; + +Server.prototype.buildPackageFromUrl = function(reqUrl) { + var options = getOptionsFromPath(url.parse(reqUrl).pathname); + return this._buildPackage(options); +}; + +Server.prototype.getDependencies = function(main) { + return this._packager.getDependencies(main); +}; + +Server.prototype._processDebugRequest = function(reqUrl, res) { + var ret = ''; + var pathname = url.parse(reqUrl).pathname; + var parts = pathname.split('/').filter(Boolean); + if (parts.length === 1) { + ret += ''; + ret += ''; + res.end(ret); + } else if (parts[1] === 'packages') { + ret += '

Cached Packages

'; + q.all(Object.keys(this._packages).map(function(url) { + return this._packages[url].then(function(p) { + ret += '

' + url + '

'; + ret += p.getDebugInfo(); + }); + }, this)).then( + function() { res.end(ret); }, + function(e) { + res.wrteHead(500); + res.end('Internal Error'); + console.log(e.stack); + } + ); + } else if (parts[1] === 'graph'){ + ret += '

Dependency Graph

'; + ret += this._packager.getGraphDebugInfo(); + res.end(ret); + } else { + res.writeHead('404'); + res.end('Invalid debug request'); + return; + } +}; + +Server.prototype.processRequest = function(req, res, next) { + var requestType; + if (req.url.match(/\.bundle$/)) { + requestType = 'bundle'; + } else if (req.url.match(/\.map$/)) { + requestType = 'map'; + } else if (req.url.match(/^\/debug/)) { + this._processDebugRequest(req.url, res); + return; + } else { + return next(); + } + + var startReqEventId = Activity.startEvent('request:' + req.url); + var options = getOptionsFromPath(url.parse(req.url).pathname); + var building = this._packages[req.url] || this._buildPackage(options) + this._packages[req.url] = building; + building.then( + function(p) { + if (requestType === 'bundle') { + res.end(p.getSource()); + Activity.endEvent(startReqEventId); + } else if (requestType === 'map') { + res.end(JSON.stringify(p.getSourceMap())); + Activity.endEvent(startReqEventId); + } + }, + function(error) { + handleError(res, error); + } + ).done(); +}; + +function getOptionsFromPath(pathname) { + var parts = pathname.split('.'); + // Remove the leading slash. + var main = parts[0].slice(1) + '.js'; + return { + runModule: parts.slice(1).some(function(part) { + return part === 'runModule'; + }), + main: main, + sourceMapUrl: parts.slice(0, -1).join('.') + '.map' + }; +} + +function handleError(res, error) { + res.writeHead(500, { + 'Content-Type': 'application/json; charset=UTF-8', + }); + + if (error.type === 'TransformError') { + res.end(JSON.stringify(error)); + } else { + console.error(error.stack || error); + res.end(JSON.stringify({ + type: 'InternalError', + message: 'react-packager has encountered an internal error, ' + + 'please check your terminal error output for more details', + })); + } +} diff --git a/packager/react-packager/src/fb-path-utils/index.js b/packager/react-packager/src/fb-path-utils/index.js new file mode 100644 index 0000000000..b4a1cb9677 --- /dev/null +++ b/packager/react-packager/src/fb-path-utils/index.js @@ -0,0 +1,14 @@ +var absolutePath = require('absolute-path'); +var path = require('path'); +var pathIsInside = require('path-is-inside'); + +function isAbsolutePath(pathStr) { + return absolutePath(pathStr); +} + +function isChildPath(parentPath, childPath) { + return pathIsInside(parentPath, childPath); +} + +exports.isAbsolutePath = isAbsolutePath; +exports.isChildPath = isChildPath; diff --git a/packager/transformer.js b/packager/transformer.js new file mode 100644 index 0000000000..df5e7143d9 --- /dev/null +++ b/packager/transformer.js @@ -0,0 +1,57 @@ +/** + * Copyright 2004-present Facebook. All Rights Reserved. + * + * Note: This is a fork of the fb-specific transform.js + */ +'use strict'; + +var jstransform = require('jstransform').transform; + +var reactVisitors = + require('react-tools/vendor/fbtransform/visitors').getAllVisitors(); +var staticTypeSyntax = + require('jstransform/visitors/type-syntax').visitorList; +// Note that reactVisitors now handles ES6 classes, rest parameters, arrow +// functions, template strings, and object short notation. +var visitorList = reactVisitors; + + +function transform(transformSets, srcTxt, options) { + options = options || {}; + + // These tranforms mostly just erase type annotations and static typing + // related statements, but they were conflicting with other tranforms. + // Running them first solves that problem + var staticTypeSyntaxResult = jstransform( + staticTypeSyntax, + srcTxt + ); + + return jstransform(visitorList, staticTypeSyntaxResult.code); +} + +module.exports = function(data, callback) { + var result; + try { + result = transform( + data.transformSets, + data.sourceCode, + data.options + ); + } catch (e) { + return callback(null, { + error: { + lineNumber: e.lineNumber, + column: e.column, + message: e.message, + stack: e.stack, + description: e.description + } + }); + } + + callback(null, result); +}; + +// export for use in jest +module.exports.transform = transform; diff --git a/website/.gitignore b/website/.gitignore new file mode 100644 index 0000000000..37a25d50ab --- /dev/null +++ b/website/.gitignore @@ -0,0 +1,2 @@ +src/react-native/docs/** +core/metadata.js diff --git a/website/README.md b/website/README.md new file mode 100644 index 0000000000..f581dd5b7a --- /dev/null +++ b/website/README.md @@ -0,0 +1,36 @@ +# Run the server + +The first time, get all the dependencies loaded via + +``` +npm install +``` + +Then, run the server via + +``` +npm start +Open http://localhost:8080/react-native/index.html +``` + +Anytime you change the contents, just refresh the page and it's going to be updated + +# Publish the website + +First setup your environment by having two folders, one `react-native` and one `react-native-gh-pages`. The publish script expects those exact names. + +``` +cd ../../ +git clone git@github.com:facebook/react-native.git react-native-gh-pages +cd react-native-gh-pages +git checkout origin/gh-pages +git checkout -b gh-pages +git push --set-upstream origin gh-pages +cd ../react-native/website +``` + +Then, after you've done changes, just run the command and it'll automatically build the static version of the site and publish it to gh-pages. + +``` +./publish.sh +``` diff --git a/website/core/DocsSidebar.js b/website/core/DocsSidebar.js new file mode 100644 index 0000000000..b730acb0dd --- /dev/null +++ b/website/core/DocsSidebar.js @@ -0,0 +1,96 @@ +/** + * @providesModule DocsSidebar + * @jsx React.DOM + */ + +var React = require('React'); +var Metadata = require('Metadata'); + +var DocsSidebar = React.createClass({ + getCategories: function() { + var metadatas = Metadata.files.filter(function(metadata) { + return metadata.layout === 'docs'; + }); + + // Build a hashmap of article_id -> metadata + var articles = {}; + for (var i = 0; i < metadatas.length; ++i) { + var metadata = metadatas[i]; + articles[metadata.id] = metadata; + } + + // Build a hashmap of article_id -> previous_id + var previous = {}; + for (var i = 0; i < metadatas.length; ++i) { + var metadata = metadatas[i]; + if (metadata.next) { + if (!articles[metadata.next]) { + throw '`next: ' + metadata.next + '` in ' + metadata.id + ' doesn\'t exist'; + } + previous[articles[metadata.next].id] = metadata.id; + } + } + + // Find the first element which doesn't have any previous + var first = null; + for (var i = 0; i < metadatas.length; ++i) { + var metadata = metadatas[i]; + if (!previous[metadata.id]) { + first = metadata; + break; + } + } + + var categories = []; + var currentCategory = null; + + var metadata = first; + var i = 0; + while (metadata && i++ < 1000) { + if (!currentCategory || metadata.category !== currentCategory.name) { + currentCategory && categories.push(currentCategory); + currentCategory = { + name: metadata.category, + links: [] + }; + } + currentCategory.links.push(metadata); + metadata = articles[metadata.next]; + } + categories.push(currentCategory); + + return categories; + }, + + getLink: function(metadata) { + if (metadata.permalink.match(/^https?:/)) { + return metadata.permalink; + } + return '/react-native/' + metadata.permalink + '#content'; + }, + + render: function() { + return
+ {this.getCategories().map((category) => +
+

{category.name}

+ +
+ )} +
; + } +}); + +module.exports = DocsSidebar; diff --git a/website/core/H2.js b/website/core/H2.js new file mode 100644 index 0000000000..3b67f42ecb --- /dev/null +++ b/website/core/H2.js @@ -0,0 +1,17 @@ +/** + * @providesModule H2 + * @jsx React.DOM + */ + +var React = require('React'); +var Header = require('Header'); + +var H2 = React.createClass({ + render: function() { + return this.transferPropsTo( +
{this.props.children}
+ ); + } +}); + +module.exports = H2; diff --git a/website/core/Header.js b/website/core/Header.js new file mode 100644 index 0000000000..f49cae3747 --- /dev/null +++ b/website/core/Header.js @@ -0,0 +1,24 @@ +/** + * @providesModule Header + * @jsx React.DOM + */ + +var React = require('React'); +var slugify = require('slugify'); + +var Header = React.createClass({ + render: function() { + var slug = slugify(this.props.toSlug || this.props.children); + var H = React.DOM['h' + this.props.level]; + + return this.transferPropsTo( + + + {this.props.children} + {' '}# + + ); + } +}); + +module.exports = Header; diff --git a/website/core/HeaderLinks.js b/website/core/HeaderLinks.js new file mode 100644 index 0000000000..86b7492856 --- /dev/null +++ b/website/core/HeaderLinks.js @@ -0,0 +1,34 @@ +/** + * @providesModule HeaderLinks + * @jsx React.DOM + */ + +var React = require('React'); + +var HeaderLinks = React.createClass({ + links: [ + {section: 'docs', href: '/react-native/docs/getting-started.html#content', text: 'docs'}, + {section: 'support', href: '/react-native/support.html', text: 'support'}, + {section: 'github', href: 'http://github.com/facebook/react-native', text: 'github'}, + ], + + render: function() { + return ( +
    + {this.links.map(function(link) { + return ( +
  • + + {link.text} + +
  • + ); + }, this)} +
+ ); + } +}); + +module.exports = HeaderLinks; diff --git a/website/core/Marked.js b/website/core/Marked.js new file mode 100644 index 0000000000..f9baeaf49b --- /dev/null +++ b/website/core/Marked.js @@ -0,0 +1,1092 @@ +/** + * marked - a markdown parser + * Copyright (c) 2011-2013, Christopher Jeffrey. (MIT Licensed) + * https://github.com/chjj/marked + * + * @providesModule Marked + * @jsx React.DOM + */ + +var React = require('React'); +var Prism = require('Prism'); +var Header = require('Header'); + +/** + * Block-Level Grammar + */ + +var block = { + newline: /^\n+/, + code: /^( {4}[^\n]+\n*)+/, + fences: noop, + hr: /^( *[-*_]){3,} *(?:\n+|$)/, + heading: /^ *(#{1,6}) *([^\n]+?) *#* *(?:\n+|$)/, + nptable: noop, + lheading: /^([^\n]+)\n *(=|-){3,} *\n*/, + blockquote: /^( *>[^\n]+(\n[^\n]+)*\n*)+/, + list: /^( *)(bull) [\s\S]+?(?:hr|\n{2,}(?! )(?!\1bull )\n*|\s*$)/, + html: /^ *(?:comment|closed|closing) *(?:\n{2,}|\s*$)/, + def: /^ *\[([^\]]+)\]: *]+)>?(?: +["(]([^\n]+)[")])? *(?:\n+|$)/, + table: noop, + paragraph: /^((?:[^\n]+\n?(?!hr|heading|lheading|blockquote|tag|def))+)\n*/, + text: /^[^\n]+/ +}; + +block.bullet = /(?:[*+-]|\d+\.)/; +block.item = /^( *)(bull) [^\n]*(?:\n(?!\1bull )[^\n]*)*/; +block.item = replace(block.item, 'gm') + (/bull/g, block.bullet) + (); + +block.list = replace(block.list) + (/bull/g, block.bullet) + ('hr', /\n+(?=(?: *[-*_]){3,} *(?:\n+|$))/) + (); + +block._tag = '(?!(?:' + + 'a|em|strong|small|s|cite|q|dfn|abbr|data|time|code' + + '|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo' + + '|span|br|wbr|ins|del|img)\\b)\\w+(?!:/|@)\\b'; + +block.html = replace(block.html) + ('comment', //) + ('closed', /<(tag)[\s\S]+?<\/\1>/) + ('closing', /])*?>/) + (/tag/g, block._tag) + (); + +block.paragraph = replace(block.paragraph) + ('hr', block.hr) + ('heading', block.heading) + ('lheading', block.lheading) + ('blockquote', block.blockquote) + ('tag', '<' + block._tag) + ('def', block.def) + (); + +/** + * Normal Block Grammar + */ + +block.normal = merge({}, block); + +/** + * GFM Block Grammar + */ + +block.gfm = merge({}, block.normal, { + fences: /^ *(`{3,}|~{3,}) *(\S+)? *\n([\s\S]+?)\s*\1 *(?:\n+|$)/, + paragraph: /^/ +}); + +block.gfm.paragraph = replace(block.paragraph) + ('(?!', '(?!' + block.gfm.fences.source.replace('\\1', '\\2') + '|') + (); + +/** + * GFM + Tables Block Grammar + */ + +block.tables = merge({}, block.gfm, { + nptable: /^ *(\S.*\|.*)\n *([-:]+ *\|[-| :]*)\n((?:.*\|.*(?:\n|$))*)\n*/, + table: /^ *\|(.+)\n *\|( *[-:]+[-| :]*)\n((?: *\|.*(?:\n|$))*)\n*/ +}); + +/** + * Block Lexer + */ + +function Lexer(options) { + this.tokens = []; + this.tokens.links = {}; + this.options = options || marked.defaults; + this.rules = block.normal; + + if (this.options.gfm) { + if (this.options.tables) { + this.rules = block.tables; + } else { + this.rules = block.gfm; + } + } +} + +/** + * Expose Block Rules + */ + +Lexer.rules = block; + +/** + * Static Lex Method + */ + +Lexer.lex = function(src, options) { + var lexer = new Lexer(options); + return lexer.lex(src); +}; + +/** + * Preprocessing + */ + +Lexer.prototype.lex = function(src) { + src = src + .replace(/\r\n|\r/g, '\n') + .replace(/\t/g, ' ') + .replace(/\u00a0/g, ' ') + .replace(/\u2424/g, '\n'); + + return this.token(src, true); +}; + +/** + * Lexing + */ + +Lexer.prototype.token = function(src, top) { + var src = src.replace(/^ +$/gm, '') + , next + , loose + , cap + , bull + , b + , item + , space + , i + , l; + + while (src) { + // newline + if (cap = this.rules.newline.exec(src)) { + src = src.substring(cap[0].length); + if (cap[0].length > 1) { + this.tokens.push({ + type: 'space' + }); + } + } + + // code + if (cap = this.rules.code.exec(src)) { + src = src.substring(cap[0].length); + cap = cap[0].replace(/^ {4}/gm, ''); + this.tokens.push({ + type: 'code', + text: !this.options.pedantic + ? cap.replace(/\n+$/, '') + : cap + }); + continue; + } + + // fences (gfm) + if (cap = this.rules.fences.exec(src)) { + src = src.substring(cap[0].length); + this.tokens.push({ + type: 'code', + lang: cap[2], + text: cap[3] + }); + continue; + } + + // heading + if (cap = this.rules.heading.exec(src)) { + src = src.substring(cap[0].length); + this.tokens.push({ + type: 'heading', + depth: cap[1].length, + text: cap[2] + }); + continue; + } + + // table no leading pipe (gfm) + if (top && (cap = this.rules.nptable.exec(src))) { + src = src.substring(cap[0].length); + + item = { + type: 'table', + header: cap[1].replace(/^ *| *\| *$/g, '').split(/ *\| */), + align: cap[2].replace(/^ *|\| *$/g, '').split(/ *\| */), + cells: cap[3].replace(/\n$/, '').split('\n') + }; + + for (i = 0; i < item.align.length; i++) { + if (/^ *-+: *$/.test(item.align[i])) { + item.align[i] = 'right'; + } else if (/^ *:-+: *$/.test(item.align[i])) { + item.align[i] = 'center'; + } else if (/^ *:-+ *$/.test(item.align[i])) { + item.align[i] = 'left'; + } else { + item.align[i] = null; + } + } + + for (i = 0; i < item.cells.length; i++) { + item.cells[i] = item.cells[i].split(/ *\| */); + } + + this.tokens.push(item); + + continue; + } + + // lheading + if (cap = this.rules.lheading.exec(src)) { + src = src.substring(cap[0].length); + this.tokens.push({ + type: 'heading', + depth: cap[2] === '=' ? 1 : 2, + text: cap[1] + }); + continue; + } + + // hr + if (cap = this.rules.hr.exec(src)) { + src = src.substring(cap[0].length); + this.tokens.push({ + type: 'hr' + }); + continue; + } + + // blockquote + if (cap = this.rules.blockquote.exec(src)) { + src = src.substring(cap[0].length); + + this.tokens.push({ + type: 'blockquote_start' + }); + + cap = cap[0].replace(/^ *> ?/gm, ''); + + // Pass `top` to keep the current + // "toplevel" state. This is exactly + // how markdown.pl works. + this.token(cap, top); + + this.tokens.push({ + type: 'blockquote_end' + }); + + continue; + } + + // list + if (cap = this.rules.list.exec(src)) { + src = src.substring(cap[0].length); + bull = cap[2]; + + this.tokens.push({ + type: 'list_start', + ordered: bull.length > 1 + }); + + // Get each top-level item. + cap = cap[0].match(this.rules.item); + + next = false; + l = cap.length; + i = 0; + + for (; i < l; i++) { + item = cap[i]; + + // Remove the list item's bullet + // so it is seen as the next token. + space = item.length; + item = item.replace(/^ *([*+-]|\d+\.) +/, ''); + + // Outdent whatever the + // list item contains. Hacky. + if (~item.indexOf('\n ')) { + space -= item.length; + item = !this.options.pedantic + ? item.replace(new RegExp('^ {1,' + space + '}', 'gm'), '') + : item.replace(/^ {1,4}/gm, ''); + } + + // Determine whether the next list item belongs here. + // Backpedal if it does not belong in this list. + if (this.options.smartLists && i !== l - 1) { + b = block.bullet.exec(cap[i+1])[0]; + if (bull !== b && !(bull.length > 1 && b.length > 1)) { + src = cap.slice(i + 1).join('\n') + src; + i = l - 1; + } + } + + // Determine whether item is loose or not. + // Use: /(^|\n)(?! )[^\n]+\n\n(?!\s*$)/ + // for discount behavior. + loose = next || /\n\n(?!\s*$)/.test(item); + if (i !== l - 1) { + next = item[item.length-1] === '\n'; + if (!loose) loose = next; + } + + this.tokens.push({ + type: loose + ? 'loose_item_start' + : 'list_item_start' + }); + + // Recurse. + this.token(item, false); + + this.tokens.push({ + type: 'list_item_end' + }); + } + + this.tokens.push({ + type: 'list_end' + }); + + continue; + } + + // html + if (cap = this.rules.html.exec(src)) { + src = src.substring(cap[0].length); + this.tokens.push({ + type: this.options.sanitize + ? 'paragraph' + : 'html', + pre: cap[1] === 'pre' || cap[1] === 'script', + text: cap[0] + }); + continue; + } + + // def + if (top && (cap = this.rules.def.exec(src))) { + src = src.substring(cap[0].length); + this.tokens.links[cap[1].toLowerCase()] = { + href: cap[2], + title: cap[3] + }; + continue; + } + + // table (gfm) + if (top && (cap = this.rules.table.exec(src))) { + src = src.substring(cap[0].length); + + item = { + type: 'table', + header: cap[1].replace(/^ *| *\| *$/g, '').split(/ *\| */), + align: cap[2].replace(/^ *|\| *$/g, '').split(/ *\| */), + cells: cap[3].replace(/(?: *\| *)?\n$/, '').split('\n') + }; + + for (i = 0; i < item.align.length; i++) { + if (/^ *-+: *$/.test(item.align[i])) { + item.align[i] = 'right'; + } else if (/^ *:-+: *$/.test(item.align[i])) { + item.align[i] = 'center'; + } else if (/^ *:-+ *$/.test(item.align[i])) { + item.align[i] = 'left'; + } else { + item.align[i] = null; + } + } + + for (i = 0; i < item.cells.length; i++) { + item.cells[i] = item.cells[i] + .replace(/^ *\| *| *\| *$/g, '') + .split(/ *\| */); + } + + this.tokens.push(item); + + continue; + } + + // top-level paragraph + if (top && (cap = this.rules.paragraph.exec(src))) { + src = src.substring(cap[0].length); + this.tokens.push({ + type: 'paragraph', + text: cap[1][cap[1].length-1] === '\n' + ? cap[1].slice(0, -1) + : cap[1] + }); + continue; + } + + // text + if (cap = this.rules.text.exec(src)) { + // Top-level should never reach here. + src = src.substring(cap[0].length); + this.tokens.push({ + type: 'text', + text: cap[0] + }); + continue; + } + + if (src) { + throw new + Error('Infinite loop on byte: ' + src.charCodeAt(0)); + } + } + + return this.tokens; +}; + +/** + * Inline-Level Grammar + */ + +var inline = { + escape: /^\\([\\`*{}\[\]()#+\-.!_>])/, + autolink: /^<([^ >]+(@|:\/)[^ >]+)>/, + url: noop, + tag: /^|^<\/?\w+(?:"[^"]*"|'[^']*'|[^'">])*?>/, + link: /^!?\[(inside)\]\(href\)/, + reflink: /^!?\[(inside)\]\s*\[([^\]]*)\]/, + nolink: /^!?\[((?:\[[^\]]*\]|[^\[\]])*)\]/, + strong: /^__([\s\S]+?)__(?!_)|^\*\*([\s\S]+?)\*\*(?!\*)/, + em: /^\b_((?:__|[\s\S])+?)_\b|^\*((?:\*\*|[\s\S])+?)\*(?!\*)/, + code: /^(`+)\s*([\s\S]*?[^`])\s*\1(?!`)/, + br: /^ {2,}\n(?!\s*$)/, + del: noop, + text: /^[\s\S]+?(?=[\\?(?:\s+['"]([\s\S]*?)['"])?\s*/; + +inline.link = replace(inline.link) + ('inside', inline._inside) + ('href', inline._href) + (); + +inline.reflink = replace(inline.reflink) + ('inside', inline._inside) + (); + +/** + * Normal Inline Grammar + */ + +inline.normal = merge({}, inline); + +/** + * Pedantic Inline Grammar + */ + +inline.pedantic = merge({}, inline.normal, { + strong: /^__(?=\S)([\s\S]*?\S)__(?!_)|^\*\*(?=\S)([\s\S]*?\S)\*\*(?!\*)/, + em: /^_(?=\S)([\s\S]*?\S)_(?!_)|^\*(?=\S)([\s\S]*?\S)\*(?!\*)/ +}); + +/** + * GFM Inline Grammar + */ + +inline.gfm = merge({}, inline.normal, { + escape: replace(inline.escape)('])', '~|])')(), + url: /^(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/, + del: /^~~(?=\S)([\s\S]*?\S)~~/, + text: replace(inline.text) + (']|', '~]|') + ('|', '|https?://|') + () +}); + +/** + * GFM + Line Breaks Inline Grammar + */ + +inline.breaks = merge({}, inline.gfm, { + br: replace(inline.br)('{2,}', '*')(), + text: replace(inline.gfm.text)('{2,}', '*')() +}); + +/** + * Inline Lexer & Compiler + */ + +function InlineLexer(links, options) { + this.options = options || marked.defaults; + this.links = links; + this.rules = inline.normal; + + if (!this.links) { + throw new + Error('Tokens array requires a `links` property.'); + } + + if (this.options.gfm) { + if (this.options.breaks) { + this.rules = inline.breaks; + } else { + this.rules = inline.gfm; + } + } else if (this.options.pedantic) { + this.rules = inline.pedantic; + } +} + +/** + * Expose Inline Rules + */ + +InlineLexer.rules = inline; + +/** + * Static Lexing/Compiling Method + */ + +InlineLexer.output = function(src, links, options) { + var inline = new InlineLexer(links, options); + return inline.output(src); +}; + +/** + * Lexing/Compiling + */ + +InlineLexer.prototype.output = function(src) { + var out = [] + , link + , text + , href + , cap; + + while (src) { + // escape + if (cap = this.rules.escape.exec(src)) { + src = src.substring(cap[0].length); + out.push(cap[1]); + continue; + } + + // autolink + if (cap = this.rules.autolink.exec(src)) { + src = src.substring(cap[0].length); + if (cap[2] === '@') { + text = cap[1][6] === ':' + ? cap[1].substring(7) + : cap[1]; + href = 'mailto:' + text; + } else { + text = cap[1]; + href = text; + } + out.push(React.DOM.a({href: this.sanitizeUrl(href)}, text)); + continue; + } + + // url (gfm) + if (cap = this.rules.url.exec(src)) { + src = src.substring(cap[0].length); + text = cap[1]; + href = text; + out.push(React.DOM.a({href: this.sanitizeUrl(href)}, text)); + continue; + } + + // tag + if (cap = this.rules.tag.exec(src)) { + src = src.substring(cap[0].length); + // TODO(alpert): Don't escape if sanitize is false + out.push(cap[0]); + continue; + } + + // link + if (cap = this.rules.link.exec(src)) { + src = src.substring(cap[0].length); + out.push(this.outputLink(cap, { + href: cap[2], + title: cap[3] + })); + continue; + } + + // reflink, nolink + if ((cap = this.rules.reflink.exec(src)) + || (cap = this.rules.nolink.exec(src))) { + src = src.substring(cap[0].length); + link = (cap[2] || cap[1]).replace(/\s+/g, ' '); + link = this.links[link.toLowerCase()]; + if (!link || !link.href) { + out.push.apply(out, this.output(cap[0][0])); + src = cap[0].substring(1) + src; + continue; + } + out.push(this.outputLink(cap, link)); + continue; + } + + // strong + if (cap = this.rules.strong.exec(src)) { + src = src.substring(cap[0].length); + out.push(React.DOM.strong(null, this.output(cap[2] || cap[1]))); + continue; + } + + // em + if (cap = this.rules.em.exec(src)) { + src = src.substring(cap[0].length); + out.push(React.DOM.em(null, this.output(cap[2] || cap[1]))); + continue; + } + + // code + if (cap = this.rules.code.exec(src)) { + src = src.substring(cap[0].length); + out.push(React.DOM.code(null, cap[2])); + continue; + } + + // br + if (cap = this.rules.br.exec(src)) { + src = src.substring(cap[0].length); + out.push(React.DOM.br(null, null)); + continue; + } + + // del (gfm) + if (cap = this.rules.del.exec(src)) { + src = src.substring(cap[0].length); + out.push(React.DOM.del(null, this.output(cap[1]))); + continue; + } + + // text + if (cap = this.rules.text.exec(src)) { + src = src.substring(cap[0].length); + out.push(this.smartypants(cap[0])); + continue; + } + + if (src) { + throw new + Error('Infinite loop on byte: ' + src.charCodeAt(0)); + } + } + + return out; +}; + +/** + * Sanitize a URL for a link or image + */ + +InlineLexer.prototype.sanitizeUrl = function(url) { + if (this.options.sanitize) { + try { + var prot = decodeURIComponent(url) + .replace(/[^A-Za-z0-9:]/g, '') + .toLowerCase(); + if (prot.indexOf('javascript:') === 0) { + return '#'; + } + } catch (e) { + return '#'; + } + } + return url; +}; + +/** + * Compile Link + */ + +InlineLexer.prototype.outputLink = function(cap, link) { + if (cap[0][0] !== '!') { + var shouldOpenInNewWindow = + link.href.charAt(0) !== '/' + && link.href.charAt(0) !== '#'; + + return React.DOM.a({ + href: this.sanitizeUrl(link.href), + title: link.title, + target: shouldOpenInNewWindow ? '_blank' : '' + }, this.output(cap[1])); + } else { + return React.DOM.img({ + src: this.sanitizeUrl(link.href), + alt: cap[1], + title: link.title + }, null); + } +}; + +/** + * Smartypants Transformations + */ + +InlineLexer.prototype.smartypants = function(text) { + if (!this.options.smartypants) return text; + return text + .replace(/--/g, '\u2014') + .replace(/'([^']*)'/g, '\u2018$1\u2019') + .replace(/"([^"]*)"/g, '\u201C$1\u201D') + .replace(/\.{3}/g, '\u2026'); +}; + +/** + * Parsing & Compiling + */ + +function Parser(options) { + this.tokens = []; + this.token = null; + this.options = options || marked.defaults; +} + +/** + * Static Parse Method + */ + +Parser.parse = function(src, options) { + var parser = new Parser(options); + return parser.parse(src); +}; + +/** + * Parse Loop + */ + +Parser.prototype.parse = function(src) { + this.inline = new InlineLexer(src.links, this.options); + this.tokens = src.reverse(); + + var out = []; + while (this.next()) { + out.push(this.tok()); + } + + return out; +}; + +/** + * Next Token + */ + +Parser.prototype.next = function() { + return this.token = this.tokens.pop(); +}; + +/** + * Preview Next Token + */ + +Parser.prototype.peek = function() { + return this.tokens[this.tokens.length-1] || 0; +}; + +/** + * Parse Text Tokens + */ + +Parser.prototype.parseText = function() { + var body = this.token.text; + + while (this.peek().type === 'text') { + body += '\n' + this.next().text; + } + + return this.inline.output(body); +}; + +/** + * Parse Current Token + */ + +Parser.prototype.tok = function() { + switch (this.token.type) { + case 'space': { + return []; + } + case 'hr': { + return React.DOM.hr(null, null); + } + case 'heading': { + return Header( + {level: this.token.depth, toSlug: this.token.text}, + this.inline.output(this.token.text) + ); + } + case 'code': { + return Prism(null, this.token.text); + } + case 'table': { + var table = [] + , body = [] + , row = [] + , heading + , i + , cells + , j; + + // header + for (i = 0; i < this.token.header.length; i++) { + heading = this.inline.output(this.token.header[i]); + row.push(React.DOM.th( + this.token.align[i] + ? {style: {textAlign: this.token.align[i]}} + : null, + heading + )); + } + table.push(React.DOM.thead(null, React.DOM.tr(null, row))); + + // body + for (i = 0; i < this.token.cells.length; i++) { + row = []; + cells = this.token.cells[i]; + for (j = 0; j < cells.length; j++) { + row.push(React.DOM.td( + this.token.align[j] + ? {style: {textAlign: this.token.align[j]}} + : null, + this.inline.output(cells[j]) + )); + } + body.push(React.DOM.tr(null, row)); + } + table.push(React.DOM.thead(null, body)); + + return React.DOM.table(null, table); + } + case 'blockquote_start': { + var body = []; + + while (this.next().type !== 'blockquote_end') { + body.push(this.tok()); + } + + return React.DOM.blockquote(null, body); + } + case 'list_start': { + var type = this.token.ordered ? 'ol' : 'ul' + , body = []; + + while (this.next().type !== 'list_end') { + body.push(this.tok()); + } + + return React.DOM[type](null, body); + } + case 'list_item_start': { + var body = []; + + while (this.next().type !== 'list_item_end') { + body.push(this.token.type === 'text' + ? this.parseText() + : this.tok()); + } + + return React.DOM.li(null, body); + } + case 'loose_item_start': { + var body = []; + + while (this.next().type !== 'list_item_end') { + body.push(this.tok()); + } + + return React.DOM.li(null, body); + } + case 'html': { + return !this.token.pre && !this.options.pedantic + ? this.inline.output(this.token.text) + : this.token.text; + } + case 'paragraph': { + return this.options.paragraphFn + ? this.options.paragraphFn.call(null, this.inline.output(this.token.text)) + : React.DOM.p(null, this.inline.output(this.token.text)); + } + case 'text': { + return this.options.paragraphFn + ? this.options.paragraphFn.call(null, this.parseText()) + : React.DOM.p(null, this.parseText()); + } + } +}; + +/** + * Helpers + */ + +function escape(html, encode) { + return html + .replace(!encode ? /&(?!#?\w+;)/g : /&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function replace(regex, opt) { + regex = regex.source; + opt = opt || ''; + return function self(name, val) { + if (!name) return new RegExp(regex, opt); + val = val.source || val; + val = val.replace(/(^|[^\[])\^/g, '$1'); + regex = regex.replace(name, val); + return self; + }; +} + +function noop() {} +noop.exec = noop; + +function merge(obj) { + var i = 1 + , target + , key; + + for (; i < arguments.length; i++) { + target = arguments[i]; + for (key in target) { + if (Object.prototype.hasOwnProperty.call(target, key)) { + obj[key] = target[key]; + } + } + } + + return obj; +} + +/** + * Marked + */ + +function marked(src, opt, callback) { + if (callback || typeof opt === 'function') { + if (!callback) { + callback = opt; + opt = null; + } + + if (opt) opt = merge({}, marked.defaults, opt); + + var highlight = opt.highlight + , tokens + , pending + , i = 0; + + try { + tokens = Lexer.lex(src, opt) + } catch (e) { + return callback(e); + } + + pending = tokens.length; + + var done = function(hi) { + var out, err; + + if (hi !== true) { + delete opt.highlight; + } + + try { + out = Parser.parse(tokens, opt); + } catch (e) { + err = e; + } + + opt.highlight = highlight; + + return err + ? callback(err) + : callback(null, out); + }; + + if (!highlight || highlight.length < 3) { + return done(true); + } + + if (!pending) return done(); + + for (; i < tokens.length; i++) { + (function(token) { + if (token.type !== 'code') { + return --pending || done(); + } + return highlight(token.text, token.lang, function(err, code) { + if (code == null || code === token.text) { + return --pending || done(); + } + token.text = code; + token.escaped = true; + --pending || done(); + }); + })(tokens[i]); + } + + return; + } + try { + if (opt) opt = merge({}, marked.defaults, opt); + return Parser.parse(Lexer.lex(src, opt), opt); + } catch (e) { + e.message += '\nPlease report this to https://github.com/chjj/marked.'; + if ((opt || marked.defaults).silent) { + return [React.DOM.p(null, "An error occurred:"), + React.DOM.pre(null, e.message)]; + } + throw e; + } +} + +/** + * Options + */ + +marked.options = +marked.setOptions = function(opt) { + merge(marked.defaults, opt); + return marked; +}; + +marked.defaults = { + gfm: true, + tables: true, + breaks: false, + pedantic: false, + sanitize: false, + smartLists: false, + silent: false, + highlight: null, + langPrefix: 'lang-', + smartypants: false, + paragraphFn: null +}; + +/** + * Expose + */ + +marked.Parser = Parser; +marked.parser = Parser.parse; + +marked.Lexer = Lexer; +marked.lexer = Lexer.lex; + +marked.InlineLexer = InlineLexer; +marked.inlineLexer = InlineLexer.output; + +marked.parse = marked; + +var Marked = React.createClass({ + render: function() { + return React.DOM.div(null, marked(this.props.children, this.props)); + } +}); + +module.exports = Marked; diff --git a/website/core/Prism.js b/website/core/Prism.js new file mode 100644 index 0000000000..ef5d54f64a --- /dev/null +++ b/website/core/Prism.js @@ -0,0 +1,357 @@ +/** + * Prism: Lightweight, robust, elegant syntax highlighting + * MIT license http://www.opensource.org/licenses/mit-license.php/ + * @author Lea Verou http://lea.verou.me + * + * @providesModule Prism + * @jsx React.DOM + */ + +var React = require('React'); + +var _ = { + util: { + type: function (o) { + return Object.prototype.toString.call(o).match(/\[object (\w+)\]/)[1]; + }, + + // Deep clone a language definition (e.g. to extend it) + clone: function (o) { + var type = _.util.type(o); + + switch (type) { + case 'Object': + var clone = {}; + + for (var key in o) { + if (o.hasOwnProperty(key)) { + clone[key] = _.util.clone(o[key]); + } + } + + return clone; + + case 'Array': + return o.slice(); + } + + return o; + } + }, + + languages: { + extend: function (id, redef) { + var lang = _.util.clone(_.languages[id]); + + for (var key in redef) { + lang[key] = redef[key]; + } + + return lang; + }, + + // Insert a token before another token in a language literal + insertBefore: function (inside, before, insert, root) { + root = root || _.languages; + var grammar = root[inside]; + var ret = {}; + + for (var token in grammar) { + + if (grammar.hasOwnProperty(token)) { + + if (token == before) { + + for (var newToken in insert) { + + if (insert.hasOwnProperty(newToken)) { + ret[newToken] = insert[newToken]; + } + } + } + + ret[token] = grammar[token]; + } + } + + return root[inside] = ret; + }, + + // Traverse a language definition with Depth First Search + DFS: function(o, callback) { + for (var i in o) { + callback.call(o, i, o[i]); + + if (_.util.type(o) === 'Object') { + _.languages.DFS(o[i], callback); + } + } + } + }, + + tokenize: function(text, grammar) { + var Token = _.Token; + + var strarr = [text]; + + var rest = grammar.rest; + + if (rest) { + for (var token in rest) { + grammar[token] = rest[token]; + } + + delete grammar.rest; + } + + tokenloop: for (var token in grammar) { + if(!grammar.hasOwnProperty(token) || !grammar[token]) { + continue; + } + + var pattern = grammar[token], + inside = pattern.inside, + lookbehind = !!pattern.lookbehind, + lookbehindLength = 0; + + pattern = pattern.pattern || pattern; + + for (var i=0; i text.length) { + // Something went terribly wrong, ABORT, ABORT! + break tokenloop; + } + + if (str instanceof Token) { + continue; + } + + pattern.lastIndex = 0; + + var match = pattern.exec(str); + + if (match) { + if(lookbehind) { + lookbehindLength = match[1].length; + } + + var from = match.index - 1 + lookbehindLength, + match = match[0].slice(lookbehindLength), + len = match.length, + to = from + len, + before = str.slice(0, from + 1), + after = str.slice(to + 1); + + var args = [i, 1]; + + if (before) { + args.push(before); + } + + var wrapped = new Token(token, inside? _.tokenize(match, inside) : match); + + args.push(wrapped); + + if (after) { + args.push(after); + } + + Array.prototype.splice.apply(strarr, args); + } + } + } + + return strarr; + }, + + hooks: { + all: {}, + + add: function (name, callback) { + var hooks = _.hooks.all; + + hooks[name] = hooks[name] || []; + + hooks[name].push(callback); + }, + + run: function (name, env) { + var callbacks = _.hooks.all[name]; + + if (!callbacks || !callbacks.length) { + return; + } + + for (var i=0, callback; callback = callbacks[i++];) { + callback(env); + } + } + } +}; + +var Token = _.Token = function(type, content) { + this.type = type; + this.content = content; +}; + +Token.reactify = function(o, key) { + if (typeof o == 'string') { + return o; + } + + if (Array.isArray(o)) { + return o.map(function(element, i) { + return Token.reactify(element, i); + }); + } + + var attributes = { + className: 'token ' + o.type, + key: key + }; + if (o.type == 'comment') { + attributes.spellCheck = true; + } + + return React.DOM.span(attributes, Token.reactify(o.content)); +}; + +_.languages.markup = { + 'comment': /<!--[\w\W]*?-->/g, + 'prolog': /<\?.+?\?>/, + 'doctype': /<!DOCTYPE.+?>/, + 'cdata': /<!\[CDATA\[[\w\W]*?]]>/i, + 'tag': { + pattern: /<\/?[\w:-]+\s*(?:\s+[\w:-]+(?:=(?:("|')(\\?[\w\W])*?\1|[^\s'">=]+))?\s*)*\/?>/gi, + inside: { + 'tag': { + pattern: /^<\/?[\w:-]+/i, + inside: { + 'punctuation': /^<\/?/, + 'namespace': /^[\w-]+?:/ + } + }, + 'attr-value': { + pattern: /=(?:('|")[\w\W]*?(\1)|[^\s>]+)/gi, + inside: { + 'punctuation': /=|>|"/g + } + }, + 'punctuation': /\/?>/g, + 'attr-name': { + pattern: /[\w:-]+/g, + inside: { + 'namespace': /^[\w-]+?:/ + } + } + + } + }, + 'entity': /&#?[\da-z]{1,8};/gi +}; + +_.languages.css = { + 'comment': /\/\*[\w\W]*?\*\//g, + 'atrule': { + pattern: /@[\w-]+?.*?(;|(?=\s*{))/gi, + inside: { + 'punctuation': /[;:]/g + } + }, + 'url': /url\((["']?).*?\1\)/gi, + 'selector': /[^\{\}\s][^\{\};]*(?=\s*\{)/g, + 'property': /(\b|\B)[\w-]+(?=\s*:)/ig, + 'string': /("|')(\\?.)*?\1/g, + 'important': /\B!important\b/gi, + 'ignore': /&(lt|gt|amp);/gi, + 'punctuation': /[\{\};:]/g +}; + +_.languages.insertBefore('markup', 'tag', { + 'style': { + pattern: /(<|<)style[\w\W]*?(>|>)[\w\W]*?(<|<)\/style(>|>)/ig, + inside: { + 'tag': { + pattern: /(<|<)style[\w\W]*?(>|>)|(<|<)\/style(>|>)/ig, + inside: _.languages.markup.tag.inside + }, + rest: _.languages.css + } + } +}); + +_.languages.clike = { + 'comment': { + pattern: /(^|[^\\])(\/\*[\w\W]*?\*\/|(^|[^:])\/\/.*?(\r?\n|$))/g, + lookbehind: true + }, + 'string': /("|')(\\?.)*?\1/g, + 'class-name': { + pattern: /((?:(?:class|interface|extends|implements|trait|instanceof|new)\s+)|(?:catch\s+\())[a-z0-9_\.\\]+/ig, + lookbehind: true, + inside: { + punctuation: /(\.|\\)/ + } + }, + 'keyword': /\b(if|else|while|do|for|return|in|instanceof|function|new|try|throw|catch|finally|null|break|continue)\b/g, + 'boolean': /\b(true|false)\b/g, + 'function': { + pattern: /[a-z0-9_]+\(/ig, + inside: { + punctuation: /\(/ + } + }, + 'number': /\b-?(0x[\dA-Fa-f]+|\d*\.?\d+([Ee]-?\d+)?)\b/g, + 'operator': /[-+]{1,2}|!|<=?|>=?|={1,3}|(&){1,2}|\|?\||\?|\*|\/|\~|\^|\%/g, + 'ignore': /&(lt|gt|amp);/gi, + 'punctuation': /[{}[\];(),.:]/g +}; + +_.languages.javascript = _.languages.extend('clike', { + 'keyword': /\b(var|let|if|else|while|do|for|return|in|instanceof|function|get|set|new|with|typeof|try|throw|catch|finally|null|break|continue|this)\b/g, + 'number': /\b-?(0x[\dA-Fa-f]+|\d*\.?\d+([Ee]-?\d+)?|NaN|-?Infinity)\b/g +}); + +_.languages.insertBefore('javascript', 'keyword', { + 'regex': { + pattern: /(^|[^/])\/(?!\/)(\[.+?]|\\.|[^/\r\n])+\/[gim]{0,3}(?=\s*($|[\r\n,.;})]))/g, + lookbehind: true + } +}); + +_.languages.insertBefore('markup', 'tag', { + 'script': { + pattern: /(<|<)script[\w\W]*?(>|>)[\w\W]*?(<|<)\/script(>|>)/ig, + inside: { + 'tag': { + pattern: /(<|<)script[\w\W]*?(>|>)|(<|<)\/script(>|>)/ig, + inside: _.languages.markup.tag.inside + }, + rest: _.languages.javascript + } + } +}); + +var Prism = React.createClass({ + statics: { + _: _ + }, + getDefaultProps: function() { + return { + language: 'javascript' + }; + }, + render: function() { + var grammar = _.languages[this.props.language]; + return ( +
+ {Token.reactify(_.tokenize(this.props.children, grammar))} +
+ ); + } +}); + +module.exports = Prism; diff --git a/website/core/Site.js b/website/core/Site.js new file mode 100644 index 0000000000..b9a770ce1b --- /dev/null +++ b/website/core/Site.js @@ -0,0 +1,69 @@ +/** + * @providesModule Site + * @jsx React.DOM + */ + +var React = require('React'); +var HeaderLinks = require('HeaderLinks'); + +var Site = React.createClass({ + render: function() { + return ( + + + + + React Native | Build Native Apps Using React + + + + + + + + + + + + + + + +
+ + + {this.props.children} + +
+
© 2015 Facebook Inc.
+
+
+ +
+