Adding emulator, simulator UIExplorer example across docs

Summary:Current docs show an Appetize.io example for AlertIOS doc. This pull request adds that feature across all applicable iOS and Android docs. So if a doc has an example in UIExplorer, it shows up in the top right and clicking to Play should navigate to the relevant example.

The changes here also touched NavigationExperimental to fix a typo that prevented iOS deep link from working. Code was also added to help support Android deep links but there's an outstanding issue (a race condition) around how Android deep links trigger getInitialURL in NavigationRootContainer that prevents this from fully working.

For adding the docs, a few things were done outside this pull request:

1/ Release builds for UIExplorer Android and iOS apps were uploaded to Appetize.io. The Appetize.io info (public key to run the build) is embedded in the docs.
2/ The iOS build was generated by making a few changes to get a local bundle. The current UIExplorer set up doesn't support "react-native run-ios".

Regarding the Appetize bu
Closes https://github.com/facebook/react-native/pull/6306

Differential Revision: D3129651

Pulled By: bestander

fb-gh-sync-id: d296d64db8236faa36f35484bb6b362990caf934
fbshipit-source-id: d296d64db8236faa36f35484bb6b362990caf934
This commit is contained in:
Christine Abernathy 2016-04-04 06:04:54 -07:00 committed by Facebook Github Bot 6
parent 67b138e77c
commit 09958618c5
16 changed files with 407 additions and 49 deletions

View File

@ -1028,7 +1028,7 @@
LD_RUNPATH_SEARCH_PATHS = "$(inherited)";
LIBRARY_SEARCH_PATHS = "$(inherited)";
OTHER_LDFLAGS = "-ObjC";
PRODUCT_BUNDLE_IDENTIFIER = com.facebook.internal.uiexplorer.local;
PRODUCT_BUNDLE_IDENTIFIER = com.facebook.react.uiapp;
PRODUCT_NAME = UIExplorer;
TARGETED_DEVICE_FAMILY = "1,2";
WARNING_CFLAGS = (
@ -1054,7 +1054,7 @@
LD_RUNPATH_SEARCH_PATHS = "$(inherited)";
LIBRARY_SEARCH_PATHS = "$(inherited)";
OTHER_LDFLAGS = "-ObjC";
PRODUCT_BUNDLE_IDENTIFIER = com.facebook.internal.uiexplorer.local;
PRODUCT_BUNDLE_IDENTIFIER = com.facebook.react.uiapp;
PRODUCT_NAME = UIExplorer;
TARGETED_DEVICE_FAMILY = "1,2";
WARNING_CFLAGS = (

View File

@ -24,15 +24,22 @@
@end
@implementation AppDelegate
- (BOOL)application:(__unused UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
_bridge = [[RCTBridge alloc] initWithDelegate:self
launchOptions:launchOptions];
// Appetizer.io params check
NSDictionary *initProps = nil;
NSString *_routeUri = [[NSUserDefaults standardUserDefaults] stringForKey:@"route"];
if (_routeUri) {
initProps = @{@"exampleFromAppetizeParams":
[NSString stringWithFormat:@"rnuiexplorer://example/%@Example", _routeUri]};
}
RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:_bridge
moduleName:@"UIExplorerApp"
initialProperties:nil];
initialProperties:initProps];
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
UIViewController *rootViewController = [UIViewController new];

View File

@ -31,19 +31,46 @@ const {
const {
RootContainer: NavigationRootContainer,
} = NavigationExperimental;
const UIExplorerActions = require('./UIExplorerActions');
const UIExplorerExampleList = require('./UIExplorerExampleList');
const UIExplorerList = require('./UIExplorerList');
const UIExplorerNavigationReducer = require('./UIExplorerNavigationReducer');
const UIExplorerStateTitleMap = require('./UIExplorerStateTitleMap');
const URIActionMap = require('./URIActionMap');
var DRAWER_WIDTH_LEFT = 56;
type Props = {
exampleFromAppetizeParams: string,
};
type State = {
initialExampleUri: ?string,
};
class UIExplorerApp extends React.Component {
_handleOpenInitialExample: Function;
state: State;
constructor(props: Props) {
super(props);
this._handleOpenInitialExample = this._handleOpenInitialExample.bind(this);
this.state = {
initialExampleUri: props.exampleFromAppetizeParams,
};
}
componentWillMount() {
BackAndroid.addEventListener('hardwareBackPress', this._handleBackButtonPress.bind(this));
}
componentDidMount() {
// There's a race condition if we try to navigate to the specified example
// from the initial props at the same time the navigation logic is setting
// up the initial navigation state. This hack adds a delay to avoid this
// scenario. So after the initial example list is shown, we then transition
// to the initial example.
setTimeout(this._handleOpenInitialExample, 500);
}
render() {
return (
<NavigationRootContainer
@ -51,10 +78,21 @@ class UIExplorerApp extends React.Component {
ref={navRootRef => { this._navigationRootRef = navRootRef; }}
reducer={UIExplorerNavigationReducer}
renderNavigation={this._renderApp.bind(this)}
linkingActionMap={URIActionMap}
/>
);
}
_handleOpenInitialExample() {
if (this.state.initialExampleUri) {
const exampleAction = URIActionMap(this.state.initialExampleUri);
if (exampleAction && this._navigationRootRef) {
this._navigationRootRef.handleNavigation(exampleAction);
}
}
this.setState({initialExampleUri: null});
}
_renderApp(navigationState, onNavigate) {
if (!navigationState) {
return null;

View File

@ -24,14 +24,13 @@
'use strict';
const React = require('react-native');
const UIExplorerActions = require('./UIExplorerActions');
const UIExplorerList = require('./UIExplorerList.ios');
const UIExplorerExampleList = require('./UIExplorerExampleList');
const UIExplorerNavigationReducer = require('./UIExplorerNavigationReducer');
const UIExplorerStateTitleMap = require('./UIExplorerStateTitleMap');
const URIActionMap = require('./URIActionMap');
const {
Alert,
AppRegistry,
NavigationExperimental,
SnapshotViewIOS,
@ -51,32 +50,13 @@ import type { UIExplorerNavigationState } from './UIExplorerNavigationReducer';
import type { UIExplorerExample } from './UIExplorerList.ios';
function PathActionMap(path: string): ?Object {
// Warning! Hacky parsing for example code. Use a library for this!
const exampleParts = path.split('/example/');
const exampleKey = exampleParts[1];
if (exampleKey) {
if (!UIExplorerList.Modules[exampleKey]) {
Alert.alert(`${exampleKey} example could not be found!`);
return null;
}
return UIExplorerActions.ExampleAction(exampleKey);
}
return null;
}
type Props = {
exampleFromAppetizeParams: string,
};
function URIActionMap(uri: ?string): ?Object {
// Warning! Hacky parsing for example code. Use a library for this!
if (!uri) {
return null;
}
const parts = uri.split('rnuiexplorer:/');
if (!parts[1]) {
return null;
}
const path = parts[1];
return PathActionMap(path);
}
type State = {
initialExampleUri: ?string,
};
class UIExplorerApp extends React.Component {
_navigationRootRef: ?NavigationRootContainer;
@ -85,12 +65,29 @@ class UIExplorerApp extends React.Component {
_renderScene: Function;
_renderCard: Function;
_renderTitleComponent: Function;
_handleOpenInitialExample: Function;
state: State;
constructor(props: Props) {
super(props);
this._handleOpenInitialExample = this._handleOpenInitialExample.bind(this);
this.state = {
initialExampleUri: props.exampleFromAppetizeParams,
};
}
componentWillMount() {
this._renderNavigation = this._renderNavigation.bind(this);
this._renderOverlay = this._renderOverlay.bind(this);
this._renderScene = this._renderScene.bind(this);
this._renderTitleComponent = this._renderTitleComponent.bind(this);
}
componentDidMount() {
// There's a race condition if we try to navigate to the specified example
// from the initial props at the same time the navigation logic is setting
// up the initial navigation state. This hack adds a delay to avoid this
// scenario. So after the initial example list is shown, we then transition
// to the initial example.
setTimeout(this._handleOpenInitialExample, 500);
}
render() {
return (
<NavigationRootContainer
@ -102,6 +99,15 @@ class UIExplorerApp extends React.Component {
/>
);
}
_handleOpenInitialExample() {
if (this.state.initialExampleUri) {
const exampleAction = URIActionMap(this.state.initialExampleUri);
if (exampleAction && this._navigationRootRef) {
this._navigationRootRef.handleNavigation(exampleAction);
}
}
this.setState({initialExampleUri: null});
}
_renderNavigation(navigationState: UIExplorerNavigationState, onNavigate: Function) {
if (!navigationState) {
return null;

View File

@ -0,0 +1,54 @@
/**
* The examples provided by Facebook are for non-commercial testing and
* evaluation purposes only.
*
* Facebook reserves all rights not expressly granted.
*
* 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 NON INFRINGEMENT. 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.
*
* @flow
*/
'use strict';
const React = require('react-native');
const UIExplorerActions = require('./UIExplorerActions');
// $FlowFixMe : This is a platform-forked component, and flow seems to only run on iOS?
const UIExplorerList = require('./UIExplorerList');
const {
Alert,
} = React;
function PathActionMap(path: string): ?Object {
// Warning! Hacky parsing for example code. Use a library for this!
const exampleParts = path.split('/example/');
const exampleKey = exampleParts[1];
if (exampleKey) {
if (!UIExplorerList.Modules[exampleKey]) {
Alert.alert(`${exampleKey} example could not be found!`);
return null;
}
return UIExplorerActions.ExampleAction(exampleKey);
}
return null;
}
function URIActionMap(uri: ?string): ?Object {
// Warning! Hacky parsing for example code. Use a library for this!
if (!uri) {
return null;
}
const parts = uri.split('rnuiexplorer:/');
if (!parts[1]) {
return null;
}
const path = parts[1];
return PathActionMap(path);
}
module.exports = URIActionMap;

View File

@ -1,5 +1,87 @@
apply plugin: 'com.android.application'
import com.android.build.OutputFile
/**
* The react.gradle file registers a task for each build variant (e.g. bundleDebugJsAndAssets
* and bundleReleaseJsAndAssets).
* These basically call `react-native bundle` with the correct arguments during the Android build
* cycle. By default, bundleDebugJsAndAssets is skipped, as in debug/dev mode we prefer to load the
* bundle directly from the development server. Below you can see all the possible configurations
* and their defaults. If you decide to add a configuration block, make sure to add it before the
* `apply from: "react.gradle"` line.
*
* project.ext.react = [
* // the name of the generated asset file containing your JS bundle
* bundleAssetName: "index.android.bundle",
*
* // the entry file for bundle generation
* entryFile: "index.android.js",
*
* // whether to bundle JS and assets in debug mode
* bundleInDebug: false,
*
* // whether to bundle JS and assets in release mode
* bundleInRelease: true,
*
* // whether to bundle JS and assets in another build variant (if configured).
* // See http://tools.android.com/tech-docs/new-build-system/user-guide#TOC-Build-Variants
* // The configuration property is in the format 'bundleIn${productFlavor}${buildType}'
* // bundleInFreeDebug: true,
* // bundleInPaidRelease: true,
* // bundleInBeta: true,
*
* // the root of your project, i.e. where "package.json" lives
* root: "../../",
*
* // where to put the JS bundle asset in debug mode
* jsBundleDirDebug: "$buildDir/intermediates/assets/debug",
*
* // where to put the JS bundle asset in release mode
* jsBundleDirRelease: "$buildDir/intermediates/assets/release",
*
* // where to put drawable resources / React Native assets, e.g. the ones you use via
* // require('./image.png')), in debug mode
* resourcesDirDebug: "$buildDir/intermediates/res/merged/debug",
*
* // where to put drawable resources / React Native assets, e.g. the ones you use via
* // require('./image.png')), in release mode
* resourcesDirRelease: "$buildDir/intermediates/res/merged/release",
*
* // by default the gradle tasks are skipped if none of the JS files or assets change; this means
* // that we don't look at files in android/ or ios/ to determine whether the tasks are up to
* // date; if you have any other folders that you want to ignore for performance reasons (gradle
* // indexes the entire tree), add them here. Alternatively, if you have JS files in android/
* // for example, you might want to remove it from here.
* inputExcludes: ["android/**", "ios/**"]
* ]
*/
project.ext.react = [
bundleAssetName: "UIExplorerApp.android.bundle",
entryFile: file("../../UIExplorerApp.android.js"),
root: "../../../../",
inputExcludes: ["android/**", "./**"]
]
apply from: "react.gradle"
/**
* Set this to true to create three separate APKs instead of one:
* - A universal APK that works on all devices
* - An APK that only works on ARM devices
* - An APK that only works on x86 devices
* The advantage is the size of the APK is reduced by about 4MB.
* Upload all the APKs to the Play Store and people will download
* the correct one based on the CPU architecture of their device.
*/
def enableSeparateBuildPerCPUArchitecture = false
/**
* Run Proguard to shrink the Java bytecode in release builds.
*/
def enableProguardInReleaseBuilds = false
android {
compileSdkVersion 23
buildToolsVersion "23.0.1"
@ -14,10 +96,40 @@ android {
abiFilters "armeabi-v7a", "x86"
}
}
signingConfigs {
release {
storeFile file(MYAPP_RELEASE_STORE_FILE)
storePassword MYAPP_RELEASE_STORE_PASSWORD
keyAlias MYAPP_RELEASE_KEY_ALIAS
keyPassword MYAPP_RELEASE_KEY_PASSWORD
}
}
splits {
abi {
enable enableSeparateBuildPerCPUArchitecture
universalApk false
reset()
include "armeabi-v7a", "x86"
}
}
buildTypes {
release {
minifyEnabled false
minifyEnabled enableProguardInReleaseBuilds
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
signingConfig signingConfigs.release
}
}
// applicationVariants are e.g. debug, release
applicationVariants.all { variant ->
variant.outputs.each { output ->
// For each separate APK per architecture, set a unique version code as described here:
// http://tools.android.com/tech-docs/new-build-system/user-guide/apk-splits
def versionCodes = ["armeabi-v7a":1, "x86":2]
def abi = output.getFilter(OutputFile.ABI)
if (abi != null) { // null for the universal-debug, universal-release variants
output.versionCodeOverride =
versionCodes.get(abi) * 1048576 + defaultConfig.versionCode
}
}
}
}

View File

@ -1 +1,5 @@
android.useDeprecatedNdk=true
MYAPP_RELEASE_STORE_FILE=my-release-key.keystore
MYAPP_RELEASE_KEY_ALIAS=my-key-alias
MYAPP_RELEASE_STORE_PASSWORD=*****
MYAPP_RELEASE_KEY_PASSWORD=*****

View File

@ -0,0 +1,96 @@
import org.apache.tools.ant.taskdefs.condition.Os
def config = project.hasProperty("react") ? project.react : [];
def bundleAssetName = config.bundleAssetName ?: "index.android.bundle"
def entryFile = config.entryFile ?: "index.android.js"
// because elvis operator
def elvisFile(thing) {
return thing ? file(thing) : null;
}
def reactRoot = elvisFile(config.root) ?: file("../../")
def inputExcludes = config.inputExcludes ?: ["android/**", "ios/**"]
void runBefore(String dependentTaskName, Task task) {
Task dependentTask = tasks.findByPath(dependentTaskName);
if (dependentTask != null) {
dependentTask.dependsOn task
}
}
gradle.projectsEvaluated {
// Grab all build types and product flavors
def buildTypes = android.buildTypes.collect { type -> type.name }
def productFlavors = android.productFlavors.collect { flavor -> flavor.name }
// When no product flavors defined, use empty
if (!productFlavors) productFlavors.add('')
productFlavors.each { productFlavorName ->
buildTypes.each { buildTypeName ->
// Create variant and source names
def sourceName = "${buildTypeName}"
def targetName = "${sourceName.capitalize()}"
if (productFlavorName) {
sourceName = "${productFlavorName}${targetName}"
}
// React js bundle directories
def jsBundleDirConfigName = "jsBundleDir${targetName}"
def jsBundleDir = elvisFile(config."$jsBundleDirConfigName") ?:
file("$buildDir/intermediates/assets/${sourceName}")
def resourcesDirConfigName = "jsBundleDir${targetName}"
def resourcesDir = elvisFile(config."${resourcesDirConfigName}") ?:
file("$buildDir/intermediates/res/merged/${sourceName}")
def jsBundleFile = file("$jsBundleDir/$bundleAssetName")
// Bundle task name for variant
def bundleJsAndAssetsTaskName = "bundle${targetName}JsAndAssets"
def currentBundleTask = tasks.create(
name: bundleJsAndAssetsTaskName,
type: Exec) {
group = "react"
description = "bundle JS and assets for ${targetName}."
// Create dirs if they are not there (e.g. the "clean" task just ran)
doFirst {
jsBundleDir.mkdirs()
resourcesDir.mkdirs()
}
// Set up inputs and outputs so gradle can cache the result
inputs.files fileTree(dir: reactRoot, excludes: inputExcludes)
outputs.dir jsBundleDir
outputs.dir resourcesDir
// Set up the call to the react-native cli
workingDir reactRoot
// Set up dev mode
def devEnabled = !targetName.toLowerCase().contains("release")
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
commandLine "cmd", "/c", "node", "./local-cli/cli.js", "bundle", "--platform", "android", "--dev", "${devEnabled}",
"--entry-file", entryFile, "--bundle-output", jsBundleFile, "--assets-dest", resourcesDir
} else {
commandLine "node", "./local-cli/cli.js", "bundle", "--platform", "android", "--dev", "${devEnabled}",
"--entry-file", entryFile, "--bundle-output", jsBundleFile, "--assets-dest", resourcesDir
}
enabled config."bundleIn${targetName}" ?: targetName.toLowerCase().contains("release")
}
// Hook bundle${productFlavor}${buildType}JsAndAssets into the android build process
currentBundleTask.dependsOn("merge${targetName}Resources")
currentBundleTask.dependsOn("merge${targetName}Assets")
runBefore("processArmeabi-v7a${targetName}Resources", currentBundleTask)
runBefore("processX86${targetName}Resources", currentBundleTask)
runBefore("processUniversal${targetName}Resources", currentBundleTask)
runBefore("process${targetName}Resources", currentBundleTask)
}
}
}

View File

@ -20,6 +20,13 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<!-- Accepts URIs that begin with "rnuiexplorer://example” -->
<data android:scheme="rnuiexplorer" android:host="example" />
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
</application>

View File

@ -23,7 +23,32 @@ import java.util.Arrays;
import java.util.List;
import javax.annotation.Nullable;
import android.os.Bundle;
public class UIExplorerActivity extends ReactActivity {
private final String PARAM_ROUTE = "route";
private Bundle mInitialProps = null;
@Override
protected void onCreate(Bundle savedInstanceState) {
// Get remote param before calling super which uses it
Bundle bundle = getIntent().getExtras();
if (bundle != null && bundle.containsKey(PARAM_ROUTE)) {
String routeUri = new StringBuilder("rnuiexplorer://example/")
.append(bundle.getString(PARAM_ROUTE))
.append("Example")
.toString();
mInitialProps = new Bundle();
mInitialProps.putString("exampleFromAppetizeParams", routeUri);
}
super.onCreate(savedInstanceState);
}
@Override
protected Bundle getLaunchOptions() {
return mInitialProps;
}
@Override
protected String getMainComponentName() {
return "UIExplorerApp";

View File

@ -41,7 +41,7 @@ type Props = {
* events, and use this mapper to convert URIs into actions that your app can
* handle
*/
linkingActionMap: ?((uri: string) => NavigationAction),
linkingActionMap: ?((uri: ?string) => NavigationAction),
/*
* Provide this key, and the container will store the navigation state in
@ -113,7 +113,7 @@ class NavigationRootContainer extends React.Component<any, Props, State> {
}
componentDidMount(): void {
if (this.props.LinkingActionMap) {
if (this.props.linkingActionMap) {
Linking.getInitialURL().then(this._handleOpenURL.bind(this));
Platform.OS === 'ios' && Linking.addEventListener('url', this._handleOpenURLEvent);
}
@ -143,10 +143,10 @@ class NavigationRootContainer extends React.Component<any, Props, State> {
}
_handleOpenURL(url: ?string): void {
if (!this.props.LinkingActionMap) {
if (!this.props.linkingActionMap) {
return;
}
const action = this.props.LinkingActionMap(url);
const action = this.props.linkingActionMap(url);
if (action) {
this.handleNavigation(action);
}

View File

@ -412,13 +412,17 @@ var EmbeddedSimulator = React.createClass({
var metadata = this.props.metadata;
var imagePreview = metadata.platform === 'android'
? <img alt="Run example in simulator" width="170" height="338" src="img/uiexplorer_main_android.png" />
: <img alt="Run example in simulator" width="170" height="356" src="img/uiexplorer_main_ios.png" />;
return (
<div className="column-left">
<p><a className="modal-button-open"><strong>Run this example</strong></a></p>
<div className="modal-button-open modal-button-open-img">
<img alt="Run example in simulator" width="170" height="358" src="img/alertIOS.png" />
{imagePreview}
</div>
<Modal />
<Modal metadata={metadata} />
</div>
);
}
@ -426,9 +430,12 @@ var EmbeddedSimulator = React.createClass({
var Modal = React.createClass({
render: function() {
var appParams = {route: 'AlertIOS'};
var metadata = this.props.metadata;
var appParams = {route: metadata.title};
var encodedParams = encodeURIComponent(JSON.stringify(appParams));
var url = `https://appetize.io/embed/bypdk4jnjra5uwyj2kzd2aenv4?device=iphone5s&scale=70&autoplay=false&orientation=portrait&deviceColor=white&params=${encodedParams}`;
var url = metadata.platform === 'android'
? `https://appetize.io/embed/q7wkvt42v6bkr0pzt1n0gmbwfr?device=nexus5&scale=65&autoplay=false&orientation=portrait&osVersion=6.0&deviceColor=white&params=${encodedParams}`
: `https://appetize.io/embed/7vdfm9h3e6vuf4gfdm7r5rgc48?device=iphone6s&scale=60&autoplay=false&orientation=portrait&osVersion=9.2&deviceColor=white&params=${encodedParams}`;
return (
<div>

View File

@ -70,12 +70,15 @@ function getExample(componentName, componentPlatform) {
// Determines whether a component should have a link to a runnable example
function isRunnable(componentName) {
if (componentName === 'AlertIOS') {
return true;
function isRunnable(componentName, componentPlatform) {
var path = '../Examples/UIExplorer/' + componentName + 'Example.js';
if (!fs.existsSync(path)) {
path = '../Examples/UIExplorer/' + componentName + 'Example.'+ componentPlatform +'.js';
if (!fs.existsSync(path)) {
return false;
}
}
return false;
return true;
}
// Hide a component from the sidebar by making it return false from
@ -136,7 +139,7 @@ function componentsToMarkdown(type, json, filepath, i, styles) {
'platform: ' + componentPlatform,
'next: ' + next,
'sidebar: ' + shouldDisplayInSidebar(componentName),
'runnable:' + isRunnable(componentName),
'runnable:' + isRunnable(componentName, componentPlatform),
'path:' + json.filepath,
'---',
JSON.stringify(json, null, 2),

View File

@ -973,7 +973,6 @@ small code, li code, p code {
}
.modal-button-open-img {
background: #05A5D1;
height: 358px;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB