diff --git a/.gitignore b/.gitignore index dc7bbb4..042ef15 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,12 @@ packages/website/build/ packages/website/yarn.lock packages/website/i18n/* +# App packages/app/bundle.js + +# Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +packages/react-ape/ape/target diff --git a/README.md b/README.md index feed918..eb6bce7 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # [React Ape](https://raphamorim.io/react-ape) -> React Renderer to build UI interfaces using Canvas/WebGL +> React Renderer to build UI interfaces using WebAssembly/Canvas (fallback to JS/Canvas) -## [Check the Docs (raphamorim.io/react-ape)](https://raphamorim.io/react-ape) +## [Check the Docs](https://raphamorim.io/react-ape) ### DISCLAIMER: In experimental stage @@ -12,6 +12,12 @@ React Ape is a react renderer to build UI interfaces using canvas/WebGL. React A React Ape lets you build Canvas apps using React. React Ape uses the same design as React, letting you compose a rich UI from declarative components. +## Elements + +- [``](specs/view-spec.md) +- [``](specs/text-spec.md) +- [``](specs/image-spec.md) + ## Understanding the Problem *tl;dr:* [Crafting a high-performance TV user interface using React](https://netflixtechblog.com/crafting-a-high-performance-tv-user-interface-using-react-3350e5a6ad3b) (and also good to read: [60 FPS on the mobile web](https://engineering.flipboard.com/2015/02/mobile-web)) diff --git a/package.json b/package.json index 8343cff..8bfe8d1 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "license": "MIT", "scripts": { "build": "node ./scripts/rollup", + "wasm": "cd ./packages/react-ape/ape && cargo build --release --target wasm32-unknown-unknown && mv ./target/wasm32-unknown-unknown/release/ape.wasm ./", "ci": "yarn test && yarn build", "clean-node-modules": "find ./ -name 'node_modules' -exec rm -rf '{}' +", "docs-build": "cd ./packages/website && yarn run build", diff --git a/packages/app/src/App.js b/packages/app/src/App.js index 4e29d2f..fb8b78f 100644 --- a/packages/app/src/App.js +++ b/packages/app/src/App.js @@ -7,7 +7,7 @@ import { StyleSheet, registerComponent, withNavigation, -} from '../../react-ape/reactApeEntry'; +} from '../../react-ape/entry'; import Sidebar from './Sidebar'; import Grid from './Grid'; @@ -25,6 +25,18 @@ const styles = StyleSheet.create({ }, }); +const s = StyleSheet.create({ + container: { + backgroundColor: 'aliceblue', + height: 300, + width: 200, + }, + child: { + height: 100, + width: 100, + } +}); + class App extends Component { constructor(props) { super(props); @@ -55,14 +67,51 @@ class App extends Component { return null; } - return ( - - - - - - - ); + // First problem: Text style hierarchy doesn't work (it should render in orange) + // return ( + // + // {/**/} + // + // {/* + // should be in navy + // */} + // + // + + // + + // {/**/} + // + // ); + + // Second problem: Views should be relative to the parent and not the page + return [ + , + , + + ]; + + // Third problem: View backgroundColor should propagate to children + + // return ( + // + // + // + // + // + // + // ); } } diff --git a/packages/app/src/Clock.js b/packages/app/src/Clock.js index a6dd51b..e5bd582 100644 --- a/packages/app/src/Clock.js +++ b/packages/app/src/Clock.js @@ -7,7 +7,7 @@ import { Dimensions, StyleSheet, registerComponent, -} from '../../react-ape/reactApeEntry'; +} from '../../react-ape/entry'; const {width, height} = Dimensions.get('screen'); @@ -48,7 +48,9 @@ class Clock extends React.Component { render() { return ( - {this.state.time} + + {this.state.time} + {/* {this.state.time} diff --git a/packages/app/src/Grid.js b/packages/app/src/Grid.js index 61050f8..a1c882d 100644 --- a/packages/app/src/Grid.js +++ b/packages/app/src/Grid.js @@ -1,11 +1,5 @@ import React, {Component, useState, useEffect} from 'react'; -import { - Text, - View, - Image, - Dimensions, - StyleSheet, -} from '../../react-ape/reactApeEntry'; +import {Text, View, Image, Dimensions, StyleSheet} from '../../react-ape/entry'; import Loader from './Loader'; diff --git a/packages/app/src/Loader.js b/packages/app/src/Loader.js index d57c5ab..30928f3 100644 --- a/packages/app/src/Loader.js +++ b/packages/app/src/Loader.js @@ -5,7 +5,7 @@ import { registerComponent, StyleSheet, Dimensions, -} from '../../react-ape/reactApeEntry'; +} from '../../react-ape/entry'; import Spinner from './Spinner'; diff --git a/packages/app/src/Sidebar.js b/packages/app/src/Sidebar.js index 4a0196a..ca00451 100644 --- a/packages/app/src/Sidebar.js +++ b/packages/app/src/Sidebar.js @@ -6,7 +6,7 @@ import { StyleSheet, registerComponent, withFocus, -} from '../../react-ape/reactApeEntry'; +} from '../../react-ape/entry'; const {height} = Dimensions.get('window'); @@ -39,8 +39,7 @@ class Item extends React.Component { style={{ color: focused ? '#331A00' : 'white', fontSize: 24, - }} - > + }}> {text} @@ -64,8 +63,11 @@ class Sidebar extends Component { text="Rio de Janeiro" idx={120} /> - + + + + {/**/} ); diff --git a/packages/app/src/Slideshow.js b/packages/app/src/Slideshow.js index ffef6e0..5792700 100644 --- a/packages/app/src/Slideshow.js +++ b/packages/app/src/Slideshow.js @@ -1,11 +1,5 @@ import React, {Component, useState, useEffect} from 'react'; -import { - Text, - View, - Image, - Dimensions, - StyleSheet, -} from '../../react-ape/reactApeEntry'; +import {Text, View, Image, Dimensions, StyleSheet} from '../../react-ape/entry'; const {width} = Dimensions.get('window'); @@ -40,18 +34,21 @@ function Slideshow() { } } - React.useEffect(() => { - resetTimeout(); - timeoutRef.current = setTimeout( - () => - setCurrentSlide((prev) => (prev === slides.length - 1 ? 0 : prev + 1)), - delay - ); - - return () => { + React.useEffect( + () => { resetTimeout(); - }; - }, [currentSlide]); + timeoutRef.current = setTimeout( + () => + setCurrentSlide(prev => (prev === slides.length - 1 ? 0 : prev + 1)), + delay + ); + + return () => { + resetTimeout(); + }; + }, + [currentSlide] + ); return ( diff --git a/packages/docs/apis-platform.md b/packages/docs/apis-platform.md index 850e301..071ae57 100644 --- a/packages/docs/apis-platform.md +++ b/packages/docs/apis-platform.md @@ -4,4 +4,16 @@ title: Platform sidebar_label: Platform --- -## Platform +When building a cross-platform app, you'll want to re-use as much code as possible. You'll probably have different scenarios where different code might be necessary. + +For instance, you may want to implement separated visual components for `LG-webOS` and `Samsung-Tizen`. + +React-Ape provides the Platform module to easily organize your code and separate it by platform: + +```js +import { Platform } from 'react-ape'; + +console.log(Platform('webos')); // true +console.log(Platform('tizen')); // false +console.log(Platform('orsay')); // false +``` \ No newline at end of file diff --git a/packages/react-ape/__tests__/layout-test.js b/packages/react-ape/__tests__/layout-test.js index f5ec6e4..1d88524 100644 --- a/packages/react-ape/__tests__/layout-test.js +++ b/packages/react-ape/__tests__/layout-test.js @@ -1,9 +1,9 @@ import React from 'react'; -import {render, View} from '../reactApeEntry'; +import {render, View} from '../entry'; import testCanvasSnapshot from '../../../tests/testCanvasSnapshot'; describe('Layout test', () => { - describe('Test "relative" and "absolute" ', () => { + describe('Test "relative" and "absolute" Views', () => { test('Test 5 views with different positions (4 relative and 1 absolute)', () => { const canvas = document.createElement('canvas'); canvas.height = 600; @@ -68,6 +68,8 @@ describe('Layout test', () => { render(, canvas, () => testCanvasSnapshot(expect, canvas)); }); + }); + describe('BorderRadius', () => { test('Test 2 View with BorderRadius', () => { const canvas = document.createElement('canvas'); canvas.height = 600; diff --git a/packages/react-ape/__tests__/specs/view-test.js b/packages/react-ape/__tests__/specs/view-test.js new file mode 100644 index 0000000..97b2b15 --- /dev/null +++ b/packages/react-ape/__tests__/specs/view-test.js @@ -0,0 +1,28 @@ +import React from 'react'; +import {render, View, Text, StyleSheet} from '../../entry'; + +import testCanvasSnapshot from '../../../../tests/testCanvasSnapshot'; +import ViewElement from '../../renderer/elements/View'; + +describe('View Spec', () => { + it('Relative should be respected in Views of the same root level', () => { + const canvas = document.createElement('canvas'); + const app = [ + , + , + + ]; + + render(app, canvas, () => { + const dataUrl = canvas.toDataURL(); + testCanvasSnapshot(expect, canvas); + } + ); + }); +}); diff --git a/packages/react-ape/__tests__/style-hierarchy-test.js b/packages/react-ape/__tests__/style-hierarchy-test.js new file mode 100644 index 0000000..8a5344c --- /dev/null +++ b/packages/react-ape/__tests__/style-hierarchy-test.js @@ -0,0 +1,53 @@ +import React from 'react'; +import {render, View} from '../entry'; +import testCanvasSnapshot from '../../../tests/testCanvasSnapshot'; + +describe('Style hierarchy test', () => { + describe('Test style props hierarchy', () => { + test.skip('backgroundColor and color', () => { + const canvas = document.createElement('canvas'); + canvas.height = 100; + canvas.width = 100; + class Layout extends React.Component { + constructor(props) { + super(props); + } + + render() { + // Text style should render in orange + // View backgroundColor: + // - 2nd View should have same bgc as 1st + // - 4th should have same bgc as 3rd + return ( + + + + + should be in orange + + + + + ); + } + } + + render(, canvas, () => testCanvasSnapshot(expect, canvas)); + }); + }); +}); diff --git a/packages/react-ape/__tests__/text-test.js b/packages/react-ape/__tests__/text-test.js index 80d03c5..c5b4aa0 100644 --- a/packages/react-ape/__tests__/text-test.js +++ b/packages/react-ape/__tests__/text-test.js @@ -1,5 +1,5 @@ import React from 'react'; -import {render, View, Text, StyleSheet} from '../reactApeEntry'; +import {render, View, Text, StyleSheet} from '../entry'; import testCanvasSnapshot from '../../../tests/testCanvasSnapshot'; import ViewElement from '../renderer/elements/View'; diff --git a/packages/react-ape/__tests__/updates/state/text-test.js b/packages/react-ape/__tests__/updates/state/text-test.js index c60a99e..67e6dbb 100644 --- a/packages/react-ape/__tests__/updates/state/text-test.js +++ b/packages/react-ape/__tests__/updates/state/text-test.js @@ -1,11 +1,11 @@ import React from 'react'; -import {render, View, Text, StyleSheet} from '../../../reactApeEntry'; +import {render, View, Text, StyleSheet} from '../../../entry'; import testCanvasSnapshot from '../../../../../tests/testCanvasSnapshot'; describe('[Updates] State - Text', () => { describe('Text', () => { - test('Test "Text" multiples content change', (done) => { + test('Test "Text" multiples content change', done => { const canvas = document.createElement('canvas'); class TextComponent extends React.Component { constructor() { @@ -40,8 +40,12 @@ describe('[Updates] State - Text', () => { render() { return ( - {this.state.firstContent} - {this.state.secondContent} + + {this.state.firstContent} + + + {this.state.secondContent} + ); } diff --git a/packages/react-ape/__tests__/updates/state/view-test.js b/packages/react-ape/__tests__/updates/state/view-test.js index b674ddc..653b298 100644 --- a/packages/react-ape/__tests__/updates/state/view-test.js +++ b/packages/react-ape/__tests__/updates/state/view-test.js @@ -1,11 +1,11 @@ import React from 'react'; -import {render, View, Text, StyleSheet} from '../../../reactApeEntry'; +import {render, View, Text, StyleSheet} from '../../../entry'; import testCanvasSnapshot from '../../../../../tests/testCanvasSnapshot'; describe('[Updates] State - View', () => { describe('View', () => { - test('Render relative view with children updates by state', (done) => { + test('Render relative view with children updates by state', done => { const canvas = document.createElement('canvas'); class ViewComponent extends React.Component { constructor() { @@ -35,10 +35,14 @@ describe('[Updates] State - View', () => { SSSSS - {text} + + {text} + - {text} + + {text} + ABC { position: 'absolute', top: 100, left: 100, - }} - > + }}> 122121 {text} diff --git a/packages/react-ape/ape/Cargo.lock b/packages/react-ape/ape/Cargo.lock new file mode 100644 index 0000000..79640fc --- /dev/null +++ b/packages/react-ape/ape/Cargo.lock @@ -0,0 +1,147 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ape" +version = "0.1.0" +dependencies = [ + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "bumpalo" +version = "3.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37ccbd214614c6783386c1af30caf03192f17891059cecc394b4fb119e363de3" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "js-sys" +version = "0.3.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3fac17f7123a73ca62df411b1bf727ccc805daa070338fda671c86dac1bdc27" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "log" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "proc-macro2" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd96a1e8ed2596c337f8eae5f24924ec83f5ad5ab21ea8e455d3566c69fbcaf7" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcdf212e9776fbcb2d23ab029360416bb1706b1aea2d1a5ba002727cbcab804" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "syn" +version = "1.0.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c50aef8a904de4c23c788f104b7dddc7d6f79c647c7c8ce4cc8f73eb0ca773dd" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bd2fe26506023ed7b5e1e315add59d6f584c621d037f9368fea9cfb988f368c" + +[[package]] +name = "wasm-bindgen" +version = "0.2.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c53b543413a17a202f4be280a7e5c62a1c69345f5de525ee64f8cfdbc954994" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5491a68ab4500fa6b4d726bd67408630c3dbe9c4fe7bda16d5c82a1fd8c7340a" +dependencies = [ + "bumpalo", + "lazy_static", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c441e177922bc58f1e12c022624b6216378e5febc2f0533e41ba443d505b80aa" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d94ac45fcf608c1f45ef53e748d35660f168490c10b23704c7779ab8f5c3048" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a89911bd99e5f3659ec4acf9c4d93b0a90fe4a2a11f15328472058edc5261be" + +[[package]] +name = "web-sys" +version = "0.3.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fed94beee57daf8dd7d51f2b15dc2bcde92d7a72304cdf662a4371008b71b90" +dependencies = [ + "js-sys", + "wasm-bindgen", +] diff --git a/packages/react-ape/ape/Cargo.toml b/packages/react-ape/ape/Cargo.toml new file mode 100644 index 0000000..3b38048 --- /dev/null +++ b/packages/react-ape/ape/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "ape" +version = "0.1.0" +authors = ["Raphael Amorim "] +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +js-sys = "0.3.58" +wasm-bindgen = "0.2.81" + +[dependencies.web-sys] +version = "0.3.4" +features = [ + 'CanvasRenderingContext2d', + 'Document', + 'Element', + 'HtmlCanvasElement', + 'Window', +] \ No newline at end of file diff --git a/packages/react-ape/ape/ape.wasm b/packages/react-ape/ape/ape.wasm new file mode 100755 index 0000000..caa51ed Binary files /dev/null and b/packages/react-ape/ape/ape.wasm differ diff --git a/packages/react-ape/ape/src/lib.rs b/packages/react-ape/ape/src/lib.rs new file mode 100644 index 0000000..11340b3 --- /dev/null +++ b/packages/react-ape/ape/src/lib.rs @@ -0,0 +1,45 @@ +use std::f64; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsCast; + +#[wasm_bindgen(start)] +pub fn start() { + let document = web_sys::window().unwrap().document().unwrap(); + let canvas = document.get_element_by_id("canvas").unwrap(); + let canvas: web_sys::HtmlCanvasElement = canvas + .dyn_into::() + .map_err(|_| ()) + .unwrap(); + + let context = canvas + .get_context("2d") + .unwrap() + .unwrap() + .dyn_into::() + .unwrap(); + + context.begin_path(); + + // Draw the outer circle. + context + .arc(75.0, 75.0, 50.0, 0.0, f64::consts::PI * 2.0) + .unwrap(); + + // Draw the mouth. + context.move_to(110.0, 75.0); + context.arc(75.0, 75.0, 35.0, 0.0, f64::consts::PI).unwrap(); + + // Draw the left eye. + context.move_to(65.0, 65.0); + context + .arc(60.0, 65.0, 5.0, 0.0, f64::consts::PI * 2.0) + .unwrap(); + + // Draw the right eye. + context.move_to(95.0, 65.0); + context + .arc(90.0, 65.0, 5.0, 0.0, f64::consts::PI * 2.0) + .unwrap(); + + context.stroke(); +} diff --git a/packages/react-ape/reactApeEntry.js b/packages/react-ape/entry.js similarity index 88% rename from packages/react-ape/reactApeEntry.js rename to packages/react-ape/entry.js index a80eb07..14258d0 100644 --- a/packages/react-ape/reactApeEntry.js +++ b/packages/react-ape/entry.js @@ -6,9 +6,10 @@ * */ -import ReactApeRenderer from './renderer/reactApeRenderer'; +import ReactApeRenderer from './renderer/renderer'; import StyleSheetModule from './modules/StyleSheet'; import DimensionsModule from './modules/Dimensions'; +import PlatformModule from './modules/Platform'; import ListViewComponent from './renderer/components/ListView'; import RegisterComponentFn from './modules/Register'; @@ -28,6 +29,7 @@ export const Text = 'Text'; export const StyleSheet = StyleSheetModule; export const Dimensions = DimensionsModule; +export const Platform = PlatformModule; export const withFocus = withFocusFn; export const withNavigation = withNavigationFn; diff --git a/packages/react-ape/modules/Dimensions/index.js b/packages/react-ape/modules/Dimensions/index.js index c383082..a2da63b 100644 --- a/packages/react-ape/modules/Dimensions/index.js +++ b/packages/react-ape/modules/Dimensions/index.js @@ -45,7 +45,7 @@ function get(property) { } function dimensionsListener(handler) { - return (target) => { + return target => { const dimensionsValue = { window: get('window'), screen: get('screen'), diff --git a/packages/react-ape/modules/Navigation/withFocus.js b/packages/react-ape/modules/Navigation/withFocus.js index 926e259..8026224 100644 --- a/packages/react-ape/modules/Navigation/withFocus.js +++ b/packages/react-ape/modules/Navigation/withFocus.js @@ -36,7 +36,7 @@ function withFocus( super(...arguments); } - renderWithFocusPath = (focusContext) => { + renderWithFocusPath = focusContext => { // TODO: I need to listen to a global and observable focusPath that will // define if this component should be focused or not (the value of focused) const {setFocus, currentFocusPath} = focusContext; diff --git a/packages/react-ape/modules/Navigation/withNavigation.js b/packages/react-ape/modules/Navigation/withNavigation.js index 759dfa7..f329f71 100644 --- a/packages/react-ape/modules/Navigation/withNavigation.js +++ b/packages/react-ape/modules/Navigation/withNavigation.js @@ -46,16 +46,16 @@ function withNavigation( this.state = { currentFocusPath: null, }; - window.addEventListener('keydown', (e) => this.handleKeyDown(e)); + window.addEventListener('keydown', e => this.handleKeyDown(e)); } - setFocus = (currentFocusPath) => { + setFocus = currentFocusPath => { this.setState({currentFocusPath}); }; setFocusNext() {} - handleKeyDown = (e) => { + handleKeyDown = e => { const {currentFocusPath} = this.state; // arrow up/down button should select next/previous list element if (e.keyCode === 38) { @@ -84,8 +84,7 @@ function withNavigation( // rootFocusPath: this.rootFocusPath, currentFocusPath: currentFocusPath, setFocus: this.setFocus, - }} - > + }}> ); diff --git a/packages/react-ape/modules/Platform/__tests__/Platform-test.js b/packages/react-ape/modules/Platform/__tests__/Platform-test.js new file mode 100644 index 0000000..13ae7c5 --- /dev/null +++ b/packages/react-ape/modules/Platform/__tests__/Platform-test.js @@ -0,0 +1,25 @@ +import {Platform} from '../../../entry'; + +describe('Platform', () => { + it('compare with undefined/null/invalid string should return false', () => { + expect(Platform()).toEqual(false); + expect(Platform(null)).toEqual(false); + expect(Platform('')).toEqual(false); + }); + + it('[nodejs/web] platforms should return false', () => { + expect(Platform('webos')).toEqual(false); + expect(Platform('tizen')).toEqual(false); + expect(Platform('orsay')).toEqual(false); + }); + + it('[lg-webos] should return "lg-webos"', () => { + global.window.PalmSystem = {version: 1}; + + expect(Platform('webos')).toEqual(true); + expect(Platform('tizen')).toEqual(false); + expect(Platform('orsay')).toEqual(false); + + global.window.PalmSystem = null; + }); +}); diff --git a/packages/react-ape/modules/Platform/index.js b/packages/react-ape/modules/Platform/index.js new file mode 100644 index 0000000..9f333eb --- /dev/null +++ b/packages/react-ape/modules/Platform/index.js @@ -0,0 +1,35 @@ +/** + * Copyright (c) 2017-present, Raphael Amorim. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +function isLGWebOS(): boolean { + return !!(window && window.PalmSystem); +} + +function isSamsungTizen(): boolean { + return false; +} + +function isSamsungOrsay(): boolean { + return false; +} + +function Plaform(checkPlatform: string): boolean { + switch (checkPlatform) { + case 'webos': + return isLGWebOS(); + case 'tizen': + return isSamsungTizen(); + case 'orsay': + return isSamsungOrsay(); + default: + return false; + } +} + +export default Plaform; diff --git a/packages/react-ape/modules/Register/__tests__/registerComponent-test.js b/packages/react-ape/modules/Register/__tests__/registerComponent-test.js index 2c930b4..08b3b74 100644 --- a/packages/react-ape/modules/Register/__tests__/registerComponent-test.js +++ b/packages/react-ape/modules/Register/__tests__/registerComponent-test.js @@ -3,7 +3,7 @@ import React, {useState, useEffect} from 'react'; import registerComponent, {CustomComponents} from '../index'; import Spinner from '../test-helpers/Spinner'; -import {render, View, Text, StyleSheet} from '../../../reactApeEntry'; +import {render, View, Text, StyleSheet} from '../../../entry'; function testCanvasSnapshot(expect, canvas) { expect(canvas.toDataURL()).toMatchSnapshot(); @@ -30,7 +30,7 @@ describe('registerComponent', () => { render(, canvas, () => testCanvasSnapshot(expect, canvas)); }); - test.skip('should update when props change', (done) => { + test.skip('should update when props change', done => { const canvas = document.createElement('canvas'); expect(typeof registerComponent).toEqual('function'); registerComponent('Spinner', Spinner); diff --git a/packages/react-ape/modules/Register/index.js b/packages/react-ape/modules/Register/index.js index 37930d0..85aaaf4 100644 --- a/packages/react-ape/modules/Register/index.js +++ b/packages/react-ape/modules/Register/index.js @@ -9,7 +9,7 @@ export const CustomComponents = {}; function registerComponent(componentName, Component) { - CustomComponents[componentName] = (props) => { + CustomComponents[componentName] = props => { const clearRender = (prevProps, parentLayout, apeContext) => { const clearProps = { ...prevProps, diff --git a/packages/react-ape/modules/StyleSheet/index.js b/packages/react-ape/modules/StyleSheet/index.js index 2844ff2..eb52784 100644 --- a/packages/react-ape/modules/StyleSheet/index.js +++ b/packages/react-ape/modules/StyleSheet/index.js @@ -13,7 +13,7 @@ function create(styles) { let processedStyles = {}; - Object.keys(styles).forEach((styleKey) => { + Object.keys(styles).forEach(styleKey => { let style = styles[styleKey]; if (typeof style !== 'object') { return {}; diff --git a/packages/react-ape/package.json b/packages/react-ape/package.json index 8642092..213c777 100644 --- a/packages/react-ape/package.json +++ b/packages/react-ape/package.json @@ -8,6 +8,7 @@ }, "files": [ "dist/", + "ape/ape.wasm", "index.js" ], "repository": { diff --git a/packages/react-ape/renderer/apeTree/apeTree.js b/packages/react-ape/renderer/apeTree/apeTree.js new file mode 100644 index 0000000..cc144a1 --- /dev/null +++ b/packages/react-ape/renderer/apeTree/apeTree.js @@ -0,0 +1,57 @@ +const ReactApeTree = new Map(); + +// Root information +// ReactApeTree.set('root', {}); + +/* + * Internal usage for testing purpose since it is + * a major rewrite of getLayout legacy + */ +if (process.env.NODE_ENV !== 'production') { + window._reactApeTree = ReactApeTree; +} + +/* + * React Ape Tree: + * - key | value + * - randomId | ReactApeStyleNode + * + * ReactApeElement contains parent key once it's associated to the node +*/ + +// export type ReactApeStyleNode = {| +// style: number, // props +// |}; + +// export type ReactApeNode = {| +// style: ReactApeStyleNode, +// |}; + +// export type ReactApeTree = ReactApeNode[]; + +// Create Style reading parent styles and propagating +export function createStyleNodeByApeElement(apeElement) { + let styleNode = {}; + if (apeElement.props && apeElement.props.style) { + styleNode = { ...apeElement.props.style }; + } + return styleNode; +} + +// TODO: Replace ReactApeElement by layout information +export function insertNodeOnApeTree(apeId, apeElement) { + const node = { + style: createStyleNodeByApeElement(apeElement), + parent: null + }; + ReactApeTree.set(apeId, node); +} + +export function associateNodeOnApeTree(parentApeId, childApeId) { + const child = ReactApeTree.get(childApeId); + ReactApeTree.set(childApeId, {...child, parent: parentApeId}); +} + +export function getNodeById(apeId) { + return ReactApeTree.get(apeId); +} \ No newline at end of file diff --git a/packages/react-ape/renderer/reactApeComponent.js b/packages/react-ape/renderer/component.js similarity index 92% rename from packages/react-ape/renderer/reactApeComponent.js rename to packages/react-ape/renderer/component.js index 1aaa9c9..aed2b21 100644 --- a/packages/react-ape/renderer/reactApeComponent.js +++ b/packages/react-ape/renderer/component.js @@ -10,6 +10,8 @@ import Image from './elements/Image'; import Text from './elements/Text'; import View from './elements/View'; import {CustomComponents} from '../modules/Register'; +import {insertNodeOnApeTree} from './apeTree/apeTree'; +import {unsafeCreateUniqueId} from './utils'; const CHILDREN = 'children'; const STYLE = 'style'; @@ -24,17 +26,19 @@ const ReactApeComponent = { ) { // TODO: Run it once const customDict = {}; - Object.keys(CustomComponents).forEach((customKey) => { + Object.keys(CustomComponents).forEach(customKey => { customDict[customKey] = CustomComponents[customKey]( props, apeContextGlobal ); }); + const id = unsafeCreateUniqueId(); + const COMPONENTS = { ...customDict, Image: Image(props), - Text: Text(props), + Text: Text(props, id), View: new View(props), }; @@ -44,6 +48,10 @@ const ReactApeComponent = { ); } + // TODO: rethink a better way of include it + insertNodeOnApeTree(id, COMPONENTS[type]); + COMPONENTS[type].id = id; + return COMPONENTS[type]; }, diff --git a/packages/react-ape/renderer/reactApeComponentTree.js b/packages/react-ape/renderer/componentTree.js similarity index 100% rename from packages/react-ape/renderer/reactApeComponentTree.js rename to packages/react-ape/renderer/componentTree.js diff --git a/packages/react-ape/renderer/components/ListView.js b/packages/react-ape/renderer/components/ListView.js index a55472c..f420e06 100644 --- a/packages/react-ape/renderer/components/ListView.js +++ b/packages/react-ape/renderer/components/ListView.js @@ -23,7 +23,7 @@ import * as React from 'react'; type Props = {| style: {[string]: string | number}, dataSource: Array, - renderRow: (mixed) => React.Node, + renderRow: mixed => React.Node, |}; class ListView extends React.Component { diff --git a/packages/react-ape/renderer/components/__tests__/ListView-test.js b/packages/react-ape/renderer/components/__tests__/ListView-test.js index 6422a40..2651f0d 100644 --- a/packages/react-ape/renderer/components/__tests__/ListView-test.js +++ b/packages/react-ape/renderer/components/__tests__/ListView-test.js @@ -2,7 +2,7 @@ import React from 'react'; import renderer from 'react-test-renderer'; import ListView from '../ListView'; -import {View, Text} from '../../../reactApeEntry'; +import {View, Text} from '../../../entry'; describe('ListView', () => { it("should render empty view when doesn't exist dataSource", () => { @@ -15,13 +15,12 @@ describe('ListView', () => { {dog: 'Pug', age: 5}, {dog: 'Golden Retriever', age: 8}, ]; - const renderRow = (data, idx) => ( + const renderRow = (data, idx) => {data.dog}, which age is {data.age} - - ); + ; const ListViewTree = renderer .create() @@ -32,11 +31,10 @@ describe('ListView', () => { it('renders correctly', () => { const dataSource = [{name: 'Jack'}, {name: 'Russel'}]; - const renderRow = (data, idx) => ( + const renderRow = (data, idx) => {data.name} - - ); + ; const ListViewTree = renderer .create() diff --git a/packages/react-ape/renderer/config/devtools.js b/packages/react-ape/renderer/config/devtools.js deleted file mode 100644 index 34476f2..0000000 --- a/packages/react-ape/renderer/config/devtools.js +++ /dev/null @@ -1,5 +0,0 @@ -export default { - bundleType: process.env.NODE_ENV === 'production' ? 0 : 1, - version: '0.1.0', - rendererPackageName: 'ReactApe', -}; diff --git a/packages/react-ape/renderer/constants/index.js b/packages/react-ape/renderer/constants/index.js index 244fb24..c75f0f0 100644 --- a/packages/react-ape/renderer/constants/index.js +++ b/packages/react-ape/renderer/constants/index.js @@ -9,9 +9,17 @@ // Defaults for Render export const ViewDefaults = { - size: 200, // 200x200 + size: 100, // 200x200 lineHeight: 24, }; // ReactApe Internal Constants export const _SectionBlockSize: number = 80; // 80x80 + +// DevTools configuration +export const DevToolsConfig = { + // $FlowFixMe[signature-verification-failure] + bundleType: process.env.NODE_ENV === 'production' ? 0 : 1, + version: '0.1.0', + rendererPackageName: 'ReactApe', +}; diff --git a/packages/react-ape/renderer/elements/Text.js b/packages/react-ape/renderer/elements/Text.js index bc0d24a..e1dde76 100644 --- a/packages/react-ape/renderer/elements/Text.js +++ b/packages/react-ape/renderer/elements/Text.js @@ -9,6 +9,7 @@ */ import type {CanvasComponentContext, SpatialGeometry} from '../types'; +import {getNodeById} from '../apeTree/apeTree'; type Props = {| style: Style, @@ -51,6 +52,8 @@ function renderText( const {spatialGeometry = {}, relativeIndex} = parentLayout || {}; const parentStyle = (parentLayout && parentLayout.style) || {}; + console.log(1, getNodeById(this.id)); + const {style = {}, children, content} = props; const fontSize = style.fontSize || 18; const fontFamily = style.fontFamily || 'Helvetica'; @@ -111,11 +114,12 @@ function clearText( renderText(clearProps, apeContext, parentLayout); } -export default function CreateTextInstance(props: Props): mixed { +export default function CreateTextInstance(props: Props, id: string): mixed { const {style} = props; + const instanceContext = {id}; return { type: 'Text', - render: renderText.bind(this, props), + render: renderText.bind(instanceContext, props), clear: clearText, instructions: { relative: style !== 'absolute', diff --git a/packages/react-ape/renderer/elements/View.js b/packages/react-ape/renderer/elements/View.js index fed4452..f624337 100644 --- a/packages/react-ape/renderer/elements/View.js +++ b/packages/react-ape/renderer/elements/View.js @@ -7,6 +7,7 @@ */ import {ViewDefaults} from '../constants'; +import {getNodeById} from '../apeTree/apeTree'; class View { constructor(props) { @@ -98,13 +99,28 @@ class View { const previousStroke = ctx.strokeStyle; let x = style.x || style.left || 0; - let y = style.y || style.top || 0; + let y = style.y || style.top || getSurfaceHeight() || 0; const width = style.width || ViewDefaults.size; const height = style.height || ViewDefaults.size; - - // Draw borderRadius const cornerRadius = style.borderRadius || 0; + /* + Surface height controls wherever a view is in the page height + + It should be only used by Views at same level as: + + + ... should not use surface height + + + ... should not use surface height + + + Surface height should be ignored for absolute Views and also children View + */ + + const node = getNodeById(this.id); + console.log(node.parent); if (!style.position || style.position === 'relative') { const surfaceHeight = getSurfaceHeight(); y = surfaceHeight; @@ -114,8 +130,6 @@ class View { ctx.globalCompositeOperation = 'destination-over'; ctx.beginPath(); - // similar idea to ctx.rect() but to support border-radius - ctx.moveTo(x, y); // Top Right Radius ctx.lineTo(x + width - cornerRadius, y); @@ -137,7 +151,7 @@ class View { this.previousRect = {x, y, width, height, cornerRadius}; ctx.strokeStyle = style.borderColor || 'transparent'; - ctx.fillStyle = style.backgroundColor || 'transparent'; + ctx.fillStyle = style.backgroundColor || 'gray'; ctx.fill(); ctx.stroke(); ctx.closePath(); @@ -148,16 +162,18 @@ class View { this.spatialGeometry = {x, y}; - const callRenderFunctions = (renderFunction) => { + const callRenderFunctions = renderFunction => { renderFunction.render ? renderFunction.render(apeContext, { ...this.getLayoutDefinitions(), ...renderFunction.layout, }) - : null; + : undefined; }; - this.renderQueue.forEach(callRenderFunctions); + if (callRenderFunctions.length >= 1) { + this.renderQueue.forEach(callRenderFunctions); + } } } diff --git a/packages/react-ape/renderer/elements/__tests__/View-test.js b/packages/react-ape/renderer/elements/__tests__/View-test.js index 4f242b2..2883264 100644 --- a/packages/react-ape/renderer/elements/__tests__/View-test.js +++ b/packages/react-ape/renderer/elements/__tests__/View-test.js @@ -26,7 +26,7 @@ describe('View', () => { const apeContext = { ctx: { beginPath: jest.fn(), - fill: function () { + fill: function() { if (this && this.globalCompositeOperation === 'destination-over') { withDestinationOver = true; } @@ -45,8 +45,12 @@ describe('View', () => { myView.render(apeContext); - const {beginPath, closePath, fillStyle, globalCompositeOperation} = - apeContext.ctx; + const { + beginPath, + closePath, + fillStyle, + globalCompositeOperation, + } = apeContext.ctx; expect(beginPath.mock.calls.length).toBe(1); expect(closePath.mock.calls.length).toBe(1); @@ -91,7 +95,7 @@ describe('View', () => { const apeContext = { ctx: { beginPath: jest.fn(), - fill: function () { + fill: function() { if (this && this.globalCompositeOperation === 'destination-over') { withDestinationOver = true; } diff --git a/packages/react-ape/renderer/reactApeFrameScheduling.js b/packages/react-ape/renderer/frameScheduling.js similarity index 100% rename from packages/react-ape/renderer/reactApeFrameScheduling.js rename to packages/react-ape/renderer/frameScheduling.js diff --git a/packages/react-ape/renderer/reactApeRenderer.js b/packages/react-ape/renderer/renderer.js similarity index 80% rename from packages/react-ape/renderer/reactApeRenderer.js rename to packages/react-ape/renderer/renderer.js index 0f9e351..5dcbdf7 100644 --- a/packages/react-ape/renderer/reactApeRenderer.js +++ b/packages/react-ape/renderer/renderer.js @@ -9,37 +9,44 @@ import {CanvasComponentContext} from './types'; import reconciler from 'react-reconciler'; -import reactApeComponent from './reactApeComponent'; -import {scaleDPI, clearCanvas} from './core/canvas'; +import reactApeComponent from './component'; +import {DevToolsConfig} from './constants'; +import {scaleDPI} from './core/canvas'; import {renderElement, renderQueue} from './core/render'; -import {precacheFiberNode, updateFiberProps} from './reactApeComponentTree'; -import devToolsConfig from './config/devtools'; +import {precacheFiberNode, updateFiberProps} from './componentTree'; +import {associateNodeOnApeTree} from './apeTree/apeTree'; import { now as FrameSchedulingNow, cancelDeferredCallback as FrameSchedulingCancelDeferredCallback, scheduleDeferredCallback as FrameSchedulingScheduleDeferredCallback, shouldYield as FrameSchedulingShouldYield, -} from './reactApeFrameScheduling'; +} from './frameScheduling'; -// TODO: Use Context. let apeContextGlobal = null; let surfaceHeight = 0; const ReactApeFiber = reconciler({ appendInitialChild(parentInstance, child) { - if (parentInstance.appendChild && child.type !== 'View') { - let layout = {}; - if (child.instructions && child.instructions.relative) { - layout = { - ...layout, - ...parentInstance.getAndUpdateCurrentLayout(), - }; - } - parentInstance.appendChild({...child, layout}); + // if (parentInstance.appendChild && child.type !== 'View') { + // // START-TODO: delete it + // let layout = {}; + // if (child.instructions && child.instructions.relative) { + // layout = { + // ...layout, + // ...parentInstance.getAndUpdateCurrentLayout(), + // }; + // } + // parentInstance.appendChild({...child, layout}); + // child.getParentLayout = parentInstance.getLayoutDefinitions; + // // END-TODO + // } - // TODO: Change it later - child.getParentLayout = parentInstance.getLayoutDefinitions; + // TODO: it ended up adding 3 views: c(a,b,c) a(b) b(c) + if (parentInstance.appendChild) { + parentInstance.appendChild(child); } + + associateNodeOnApeTree(child.id, parentInstance.id); }, createInstance( @@ -50,23 +57,17 @@ const ReactApeFiber = reconciler({ internalInstanceHandle ) { if (!apeContextGlobal && rootContainerInstance.getContext) { - const rootContainerInstanceContext = - rootContainerInstance.getContext('2d'); + const rootContainerInstanceContext = rootContainerInstance.getContext( + '2d' + ); scaleDPI(rootContainerInstance, rootContainerInstanceContext); apeContextGlobal = { - type: 'canvas', getSurfaceHeight: () => surfaceHeight, - setSurfaceHeight: (height) => { + setSurfaceHeight: height => { surfaceHeight = height; }, ctx: rootContainerInstanceContext, - // EXPERIMENTAL: - // clear: function clear() { - // const width = rootContainerInstance.width; - // const height = rootContainerInstance.height; - // this.ctx.clearRect(0, 0, width, height); - // }, renderQueue: [], }; } @@ -91,9 +92,7 @@ const ReactApeFiber = reconciler({ }, finalizeInitialChildren(parentInstance, type, props) { - if (type === 'View') { - parentInstance.render(apeContextGlobal); - } + // Ele renderiza text, view, view (de baixo pra cima) return false; }, @@ -170,7 +169,9 @@ const ReactApeFiber = reconciler({ }, getRootHostContext(rootInstance) { - return {}; + return { + // TODO: ADICIONAR COISAS AKI + }; }, getChildHostContext() { @@ -193,18 +194,20 @@ const ReactApeFiber = reconciler({ return false; }, - appendChildToContainer(parentInstance, child) { + appendChildToContainer(container, child) { + // It goes in the container and append each child // apeContextGlobal.setSurfaceHeight(0); - // if (child.render) { - // child.render(apeContextGlobal); - // } + if (child.render) { + child.render(apeContextGlobal); + } }, appendChild(parentInstance, child) { - // console.log(parentInstance, child); + // console.log(3); // if (parentInstance.appendChild) { - // parentInstance.appendChild(child); + // parentInstance.appendChild(child); // } + // console.log(2, parentInstance, child) }, removeChild(parentInstance, child) { @@ -243,7 +246,7 @@ const ReactApeFiber = reconciler({ }); ReactApeFiber.injectIntoDevTools({ - ...devToolsConfig, + ...DevToolsConfig, findHostInstanceByFiber: ReactApeFiber.findHostInstance, }); diff --git a/packages/react-ape/renderer/utils.js b/packages/react-ape/renderer/utils.js index a1d2618..d0c7c85 100644 --- a/packages/react-ape/renderer/utils.js +++ b/packages/react-ape/renderer/utils.js @@ -6,3 +6,20 @@ export function unsafeCreateUniqueId(): string { return (Math.random() * 10e18 + Date.now()).toString(36); } + +export const isWebAssemblySupported = (() => { + try { + if ( + typeof WebAssembly === 'object' && + typeof WebAssembly.instantiate === 'function' + ) { + const module = new WebAssembly.Module( + Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00) + ); + if (module instanceof WebAssembly.Module) { + return new WebAssembly.Instance(module) instanceof WebAssembly.Instance; + } + } + } catch (e) {} + return false; +})(); diff --git a/packages/website/pages/en/index.js b/packages/website/pages/en/index.js index e300c11..e6a865b 100755 --- a/packages/website/pages/en/index.js +++ b/packages/website/pages/en/index.js @@ -98,7 +98,7 @@ const DefinitionCallout = () =>
-

Build UI interfaces using HTML5 Canvas/WebGL and React

+

Build UI interfaces using WebAssembly, Canvas and React

React Ape lets you build Canvas apps using React. React Ape uses the same design as React, letting you compose a rich UI from declarative @@ -107,18 +107,17 @@ const DefinitionCallout = () => {`\`\`\`javascript import React, { Component } from 'react'; -import { Text, View } from 'react-ape'; +import { Text, View, Platform } from 'react-ape'; class ReactApeComponent extends Component { render() { return ( - Render this text on Canvas + Render this text with WASM on Canvas - You just use React Ape components like 'View' and 'Text', - just like React Native. + { Platform('webos') && 'You are rendering in a LG WebOS' } ); diff --git a/scripts/prettier/index.js b/scripts/prettier/index.js index 28c7c77..17c8d7d 100644 --- a/scripts/prettier/index.js +++ b/scripts/prettier/index.js @@ -62,7 +62,7 @@ const changedFiles = new Set( ]).match(/[^\0]+/g) ); -Object.keys(config).forEach((key) => { +Object.keys(config).forEach(key => { const patterns = config[key].patterns; const options = config[key].options; const ignore = config[key].ignore; @@ -71,14 +71,14 @@ Object.keys(config).forEach((key) => { patterns.length > 1 ? `{${patterns.join(',')}}` : `${patterns.join(',')}`; const files = glob .sync(globPattern, {ignore}) - .filter((f) => !onlyChanged || changedFiles.has(f)); + .filter(f => !onlyChanged || changedFiles.has(f)); if (!files.length) { return; } const args = Object.keys(defaultOptions).map( - (k) => `--${k}=${(options && options[k]) || defaultOptions[k]}` + k => `--${k}=${(options && options[k]) || defaultOptions[k]}` ); args.push(`--${shouldWrite ? 'write' : 'l'}`); diff --git a/scripts/rollup/index.js b/scripts/rollup/index.js index 82cc9ce..125753c 100644 --- a/scripts/rollup/index.js +++ b/scripts/rollup/index.js @@ -95,7 +95,7 @@ function createBundle({entryPath, bundleType, destName}) { external: ['react'], input: entryPath, plugins: plugins, - }).then((bundle) => { + }).then(bundle => { tasks.push( bundle.write({ format: 'umd', @@ -110,17 +110,17 @@ function createBundle({entryPath, bundleType, destName}) { } createBundle({ - entryPath: `${packagePath}/reactApeEntry.js`, + entryPath: `${packagePath}/entry.js`, bundleType: 'production', destName: 'react-ape.production.js', }); createBundle({ - entryPath: `${packagePath}/reactApeEntry.js`, + entryPath: `${packagePath}/entry.js`, bundleType: 'development', destName: 'react-ape.development.js', }); -Promise.all(tasks).catch((error) => { +Promise.all(tasks).catch(error => { Promise.reject(error); }); diff --git a/scripts/rollup/plugins/closure-plugin.js b/scripts/rollup/plugins/closure-plugin.js index cd200ad..9ad96b1 100644 --- a/scripts/rollup/plugins/closure-plugin.js +++ b/scripts/rollup/plugins/closure-plugin.js @@ -7,7 +7,7 @@ const writeFileAsync = promisify(fs.writeFile); function compile(flags) { return new Promise((resolve, reject) => { const closureCompiler = new ClosureCompiler(flags); - closureCompiler.run(function (exitCode, stdOut, stdErr) { + closureCompiler.run(function(exitCode, stdOut, stdErr) { if (!stdErr) { resolve(stdOut); } else { diff --git a/specs/images/view-spec-relative-root.png b/specs/images/view-spec-relative-root.png new file mode 100644 index 0000000..a281108 Binary files /dev/null and b/specs/images/view-spec-relative-root.png differ diff --git a/specs/view-spec.md b/specs/view-spec.md new file mode 100644 index 0000000..977215e --- /dev/null +++ b/specs/view-spec.md @@ -0,0 +1,27 @@ +# View + +## Defaults + +- `position: relative` + +## Specs + +### 01 - Relative should be respected in Views of the same root level + +- [See the test code](https://github.com/raphamorim/react-ape/blob/main/packages/react-ape/__tests__/specs/view-test.js) + +```jsx +return [ + , + , + +]; +``` + +![relative views in root level](images/view-spec-relative-root.png) \ No newline at end of file