├── example ├── public │ ├── favicon.ico │ ├── arial_black.png │ ├── manifest.json │ ├── index.html │ └── arial_black.fnt ├── package.json └── src │ └── index.js ├── src ├── .eslintrc ├── elements │ ├── Graphics.js │ ├── Rectangle.js │ ├── TilingSprite.js │ ├── RootContainer.js │ ├── Application.js │ ├── MaskContainer.js │ ├── BitmapText.js │ ├── Text.js │ ├── BackgroundImage.js │ ├── Container.js │ ├── NineSliceSprite.js │ ├── Sprite.js │ ├── BackgroundContainer.js │ ├── BaseElement.js │ ├── ScrollContainer.js │ ├── TextInput.js │ └── BitmapTextContainer.js ├── mergeStyles.js ├── Stage.js ├── index.js └── applyLayoutProperties.js ├── .babelrc ├── .gitignore ├── rollup.config.js ├── README.md └── package.json /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lunarraid/react-pixi-layout/HEAD/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/arial_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lunarraid/react-pixi-layout/HEAD/example/public/arial_black.png -------------------------------------------------------------------------------- /src/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "globals": { 6 | "expect": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "modules": false 5 | }], 6 | "stage-0", 7 | "react" 8 | ], 9 | "plugins": [ 10 | "external-helpers" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "react-pixi-layout", 3 | "name": "react-pixi-layout", 4 | "start_url": "./index.html", 5 | "display": "standalone", 6 | "theme_color": "#000000", 7 | "background_color": "#ffffff" 8 | } 9 | -------------------------------------------------------------------------------- /src/elements/Graphics.js: -------------------------------------------------------------------------------- 1 | import { Graphics as PixiGraphics } from 'pixi.js'; 2 | import Container from './Container'; 3 | 4 | export default class Graphics extends Container { 5 | 6 | createDisplayObject () { 7 | return new PixiGraphics(); 8 | } 9 | 10 | } 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # builds 8 | build 9 | dist 10 | 11 | # misc 12 | .DS_Store 13 | .env 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /src/mergeStyles.js: -------------------------------------------------------------------------------- 1 | export default function mergeStyles (style) { 2 | if (style === null || typeof style !== 'object') { 3 | return undefined; 4 | } 5 | 6 | if (!Array.isArray(style)) { 7 | return style; 8 | } 9 | 10 | const result = {}; 11 | for (let i = 0, styleLength = style.length; i < styleLength; i++) { 12 | const computedStyle = mergeStyles(style[i]); 13 | 14 | if (computedStyle) { 15 | for (const key in computedStyle) { 16 | result[key] = computedStyle[key]; 17 | } 18 | } 19 | } 20 | 21 | return result; 22 | } 23 | -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | react-pixi-layout 12 | 13 | 14 | 15 | 18 | 19 |
20 | 21 | 22 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import nodebuiltins from 'rollup-plugin-node-builtins'; 3 | import commonjs from 'rollup-plugin-commonjs'; 4 | import external from 'rollup-plugin-peer-deps-external'; 5 | import resolve from 'rollup-plugin-node-resolve'; 6 | 7 | import pkg from './package.json'; 8 | 9 | export default { 10 | external: [ 'typeflex' ], 11 | input: 'src/index.js', 12 | output: [ 13 | { 14 | file: pkg.main, 15 | format: 'cjs', 16 | sourcemap: true 17 | }, 18 | { 19 | file: pkg.module, 20 | format: 'esm', 21 | sourcemap: true 22 | } 23 | ], 24 | plugins: [ 25 | external(), 26 | nodebuiltins(), 27 | babel({ exclude: 'node_modules/**' }), 28 | resolve(), 29 | commonjs() 30 | ] 31 | }; 32 | -------------------------------------------------------------------------------- /src/elements/Rectangle.js: -------------------------------------------------------------------------------- 1 | import TinyColor from '../util/TinyColor'; 2 | import Graphics from './Graphics'; 3 | 4 | const DEFAULT_COLOR = new TinyColor(0); 5 | 6 | export default class Rectangle extends Graphics { 7 | 8 | _color = DEFAULT_COLOR; 9 | 10 | applyProps (oldProps, newProps) { 11 | super.applyProps(oldProps, newProps); 12 | this._color = this.style.color !== undefined 13 | ? new TinyColor(this.style.color) 14 | : DEFAULT_COLOR; 15 | } 16 | 17 | onLayout (x, y, width, height) { 18 | super.onLayout(x, y, width, height); 19 | const intColor = parseInt('0x' + this._color.toHex(), 16); 20 | this.displayObject.clear(); 21 | this.displayObject.beginFill(intColor, this._color.getAlpha()); 22 | this.displayObject.drawRect(0, 0, width, height); 23 | this.displayObject.endFill(); 24 | } 25 | 26 | } 27 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-pixi-layout-example", 3 | "homepage": "https://lunarraid.github.io/react-pixi-layout", 4 | "version": "0.0.0", 5 | "private": true, 6 | "license": "MIT", 7 | "dependencies": { 8 | "pixi.js": "^5.3.0", 9 | "react": "^17.0.0", 10 | "react-dom": "^17.0.0", 11 | "react-pixi-layout": "0.2.1", 12 | "react-scripts": "^4.0.2" 13 | }, 14 | "scripts": { 15 | "start": "react-scripts start", 16 | "build": "react-scripts build", 17 | "test": "react-scripts test --env=jsdom", 18 | "eject": "react-scripts eject" 19 | }, 20 | "browserslist": { 21 | "production": [ 22 | ">0.2%", 23 | "not dead", 24 | "not op_mini all" 25 | ], 26 | "development": [ 27 | "last 1 chrome version", 28 | "last 1 firefox version", 29 | "last 1 safari version" 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-pixi-layout 2 | 3 | > React Fiber renderer for pixi.js with flexbox layout 4 | 5 | [![NPM](https://img.shields.io/npm/v/react-pixi-layout.svg)](https://www.npmjs.com/package/react-pixi-layout) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 6 | 7 | ## Example 8 | 9 | (https://codesandbox.io/s/r0lkjp8k1p) 10 | 11 | ## Install 12 | 13 | ```bash 14 | npm install --save react-pixi-layout 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```jsx 20 | import React, { Component } from 'react' 21 | 22 | import { Stage, Text } from 'react-pixi-layout' 23 | 24 | class Example extends Component { 25 | render () { 26 | return ( 27 | 28 | 29 | 30 | ); 31 | } 32 | } 33 | ``` 34 | 35 | ## License 36 | 37 | MIT © [lunarraid](https://github.com/lunarraid) 38 | -------------------------------------------------------------------------------- /src/elements/TilingSprite.js: -------------------------------------------------------------------------------- 1 | import { Texture, TilingSprite as PixiTilingSprite } from 'pixi.js'; 2 | import Container from './Container'; 3 | 4 | export default class TilingSprite extends Container { 5 | 6 | createDisplayObject () { 7 | return new PixiTilingSprite(Texture.EMPTY); 8 | } 9 | 10 | applyProps (oldProps, newProps) { 11 | super.applyProps(oldProps, newProps); 12 | const texture = newProps.texture ? Texture.from(newProps.texture) : null; 13 | this.displayObject.texture = texture; 14 | this.displayObject.tilePosition.set(newProps.offsetX || 0, newProps.offsetY || 0); 15 | this.displayObject.tileScale.set(newProps.tileScale || 1); 16 | } 17 | 18 | onLayout (x, y, width, height) { 19 | super.onLayout(x, y, width, height); 20 | if (this.displayObject.texture) { 21 | this.displayObject.width = width * this.scaleX; 22 | this.displayObject.height = height * this.scaleY; 23 | } 24 | } 25 | 26 | 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-pixi-layout", 3 | "version": "0.5.7", 4 | "description": "React Fiber renderer for pixi.js with flexbox layout", 5 | "author": "lunarraid", 6 | "license": "MIT", 7 | "repository": "lunarraid/react-pixi-layout", 8 | "main": "dist/cjs/index.js", 9 | "module": "dist/esm/index.js", 10 | "scripts": { 11 | "test": "CI=1 react-scripts test --env=jsdom", 12 | "build": "rollup -c", 13 | "start": "rollup -c -w", 14 | "prepare": "npm run build", 15 | "predeploy": "cd example && npm install && npm run build", 16 | "deploy": "gh-pages -d example/build" 17 | }, 18 | "dependencies": { 19 | "typeflex": "0.0.1" 20 | }, 21 | "peerDependencies": { 22 | "pixi.js": "^6.0.0", 23 | "react": ">=0.17.0 <= 18", 24 | "react-dom": ">=0.17.0 <= 18" 25 | }, 26 | "devDependencies": { 27 | "@lunarraid/animated": "^0.2.3", 28 | "babel-core": "^6.26.0", 29 | "babel-eslint": "^10.0.3", 30 | "babel-plugin-external-helpers": "^6.22.0", 31 | "babel-preset-env": "^1.6.0", 32 | "babel-preset-react": "^6.24.1", 33 | "babel-preset-stage-0": "^6.24.1", 34 | "gh-pages": "^2.1.1", 35 | "react-reconciler": "^0.26.2", 36 | "rollup": "^1.32.1", 37 | "rollup-plugin-babel": "^3.0.3", 38 | "rollup-plugin-commonjs": "^10.1.0", 39 | "rollup-plugin-node-builtins": "^2.1.2", 40 | "rollup-plugin-node-resolve": "^5.2.0", 41 | "rollup-plugin-peer-deps-external": "^2.2.3" 42 | }, 43 | "files": [ 44 | "dist" 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /src/elements/RootContainer.js: -------------------------------------------------------------------------------- 1 | import Container from './Container'; 2 | import mergeStyles from '../mergeStyles'; 3 | 4 | const MAX_LAYOUT_ATTEMPTS = 3; 5 | 6 | export default class RootContainer extends Container { 7 | 8 | constructor (props = {}, root) { 9 | super(props, root); 10 | this.applyProps({}, props); 11 | 12 | this.layoutCallbackViews = []; 13 | this.callbackCount = 0; 14 | this.layoutAttemptCount = 0; 15 | } 16 | 17 | addToCallbackPool (view) { 18 | this.layoutCallbackViews[this.callbackCount] = view; 19 | this.callbackCount++; 20 | } 21 | 22 | removeFromCallbackPool (view) { 23 | const views = this.layoutCallbackViews; 24 | 25 | for (let i = 0; i < this.callbackCount; i++) { 26 | if (views[i] === view) { 27 | views[i] = null; 28 | } 29 | } 30 | } 31 | 32 | updateLayout () { 33 | if (this._layoutDirty) { 34 | 35 | this.layoutAttemptCount++; 36 | this.layoutNode.calculateLayout(); 37 | this.applyLayout(); 38 | 39 | for (let i = 0; i < this.callbackCount; i++) { 40 | const view = this.layoutCallbackViews[i]; 41 | 42 | if (view) { 43 | const { x, y, width, height } = view.cachedLayout; 44 | this.layoutCallbackViews[i].onLayoutCallback(x, y, width, height); 45 | this.layoutCallbackViews[i] = null; 46 | } 47 | } 48 | 49 | this.callbackCount = 0; 50 | 51 | if (this._layoutDirty && this.layoutAttemptCount <= MAX_LAYOUT_ATTEMPTS) { 52 | this.updateLayout(); 53 | } 54 | 55 | this.layoutAttemptCount--; 56 | } 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /src/elements/Application.js: -------------------------------------------------------------------------------- 1 | import { Application as PixiApplication } from 'pixi.js'; 2 | import RootContainer from './RootContainer'; 3 | import mergeStyles from '../mergeStyles'; 4 | 5 | const MAX_LAYOUT_ATTEMPTS = 3; 6 | 7 | const optionKeys = [ 8 | 'antialias', 9 | 'autoStart', 10 | 'autoResize', 11 | 'backgroundColor', 12 | 'clearBeforeRender', 13 | 'forceCanvas', 14 | 'forceFXAA', 15 | 'height', 16 | 'legacy', 17 | 'powerPreference', 18 | 'preserveDrawingBuffer', 19 | 'resolution', 20 | 'roundPixels', 21 | 'sharedLoader', 22 | 'sharedTicker', 23 | 'transparent', 24 | 'view', 25 | 'width' 26 | ]; 27 | 28 | export default class Application extends RootContainer { 29 | 30 | constructor (props, root) { 31 | super(props, root); 32 | window.papp = this; 33 | } 34 | 35 | createDisplayObject (props) { 36 | const options = optionKeys.reduce((result, key) => { 37 | if (props.hasOwnProperty(key)) { result[key] = props[key]; } 38 | return result; 39 | }, {}); 40 | 41 | this.view = props.view; 42 | this.application = new PixiApplication(options); 43 | this.application.ticker.add(this.onTick, this); 44 | return this.application.stage; 45 | } 46 | 47 | applyProps (oldProps, newProps) { 48 | const { width, height, style } = newProps; 49 | 50 | if (oldProps.width !== width || oldProps.height !== height) { 51 | this.application.renderer.resize(width, height); 52 | this.view.style.width = '100%'; 53 | this.view.style.height = '100%'; 54 | this.layoutDirty = true; 55 | } 56 | 57 | const newStyle = { ...mergeStyles(style), width, height }; 58 | 59 | super.applyProps(oldProps, { ...newProps, style: newStyle }); 60 | } 61 | 62 | onTick () { 63 | this.updateLayout(); 64 | } 65 | 66 | destroy () { 67 | this.application.ticker.remove(this.onTick, this); 68 | super.destroy(); 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /src/Stage.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { PixiContext, ReactPixiLayout } from './index'; 3 | import Application from './elements/Application'; 4 | 5 | export default class Stage extends React.Component { 6 | 7 | state = { 8 | context: { 9 | application: null 10 | } 11 | }; 12 | 13 | componentDidMount () { 14 | const props = { view: this._canvas, ...this.props }; 15 | 16 | this._applicationElement = new Application(props); 17 | this._applicationContainer = ReactPixiLayout.createContainer(this._applicationElement); 18 | 19 | this.setState({ 20 | context: { 21 | application: this._applicationElement.application, 22 | canvas: this._canvas 23 | } 24 | }); 25 | 26 | ReactPixiLayout.injectIntoDevTools({ 27 | findFiberByHostInstance: ReactPixiLayout.findFiberByHostInstance, 28 | bundleType: 1, 29 | version: '16.8.6', 30 | rendererPackageName: 'react-pixi-layout' 31 | }); 32 | 33 | ReactPixiLayout.updateContainer(this.wrapProvider(), this._applicationContainer, this); 34 | } 35 | 36 | componentDidUpdate (prevProps) { 37 | this._applicationElement.applyProps(prevProps, this.props); 38 | 39 | ReactPixiLayout.updateContainer(this.wrapProvider(), this._applicationContainer, this); 40 | } 41 | 42 | componentWillUnmount () { 43 | ReactPixiLayout.updateContainer(null, this._applicationContainer, this); 44 | 45 | this._applicationElement.destroy(); 46 | this._applicationElement = null; 47 | this._applicationContainer = null; 48 | 49 | if (this.props.onApplication) { 50 | this.props.onApplication(null); 51 | } 52 | } 53 | 54 | wrapProvider () { 55 | return ( 56 | 57 | { this.props.children } 58 | 59 | ); 60 | } 61 | 62 | render () { 63 | return this._canvas = ref } />; 64 | } 65 | 66 | } 67 | -------------------------------------------------------------------------------- /src/elements/MaskContainer.js: -------------------------------------------------------------------------------- 1 | import { RenderTexture, Sprite, Ticker } from 'pixi.js'; 2 | 3 | import Container from './Container'; 4 | 5 | const NO_CHILDREN = []; 6 | 7 | class RenderTextureContainer extends Sprite { 8 | 9 | constructor () { 10 | super(RenderTexture.create(256, 256)); 11 | this.isRenderingToTexture = false; 12 | } 13 | 14 | destroy (options) { 15 | this.texture.destroy(true); 16 | super.destroy(options); 17 | } 18 | 19 | render (renderer) { 20 | if (this.isRenderingToTexture) { 21 | const isRenderable = this.renderable; 22 | this.renderable = true; 23 | super.render(renderer); 24 | this.renderable = isRenderable; 25 | } else { 26 | const children = this.children; 27 | this.children = NO_CHILDREN; 28 | super.render(renderer); 29 | this.children = children; 30 | } 31 | } 32 | 33 | _render (renderer) { 34 | if (!this.isRenderingToTexture) { 35 | super._render(renderer); 36 | } 37 | } 38 | 39 | renderToTexture (renderer) { 40 | this.isRenderingToTexture = true; 41 | renderer.render(this, this.texture); 42 | this.isRenderingToTexture = false; 43 | } 44 | 45 | } 46 | 47 | export default class MaskContainer extends Container { 48 | 49 | constructor (props, root) { 50 | super(props, root); 51 | Ticker.shared.add(this.onTick, this); 52 | } 53 | 54 | createDisplayObject () { 55 | return new RenderTextureContainer(); 56 | } 57 | 58 | destroy () { 59 | Ticker.shared.remove(this.onTick, this); 60 | super.destroy(); 61 | } 62 | 63 | onLayout (x, y, width, height) { 64 | super.onLayout(x, y, width, height); 65 | this.displayObject.texture.resize(width, height); 66 | } 67 | 68 | onTick () { 69 | this.displayObject.renderToTexture(this.root.application.renderer); 70 | } 71 | 72 | get isClippingEnabled () { 73 | return this._isClippingEnabled; 74 | } 75 | 76 | set isClippingEnabled (isClippingEnabled) { 77 | this._isClippingEnabled = !!isClippingEnabled; 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /src/elements/BitmapText.js: -------------------------------------------------------------------------------- 1 | import { Point } from 'pixi.js'; 2 | import BitmapTextContainer, { Align } from './BitmapTextContainer'; 3 | import BaseElement from './BaseElement'; 4 | import TinyColor from '../util/TinyColor'; 5 | 6 | const textStyleValues = { 7 | align: Align.LEFT, 8 | font: null, 9 | leading: 0, 10 | letterSpacing: 0, 11 | isKerningEnabled: true, 12 | size: 16, 13 | verticalAlign: Align.TOP, 14 | wordWrap: false 15 | }; 16 | 17 | const textStyleKeys = Object.keys(textStyleValues); 18 | const textStyleKeyCount = textStyleKeys.length; 19 | 20 | const scratchPoint = new Point(); 21 | 22 | // TODO: We need to handle font existence or not when initializing 23 | 24 | export default class BitmapText extends BaseElement { 25 | 26 | sizeData = { width: 0, height: 0 }; 27 | color = null; 28 | 29 | createDisplayObject (props) { 30 | return new BitmapTextContainer(); 31 | } 32 | 33 | applyProps (oldProps, newProps) { 34 | super.applyProps(oldProps, newProps); 35 | 36 | let color = this.style.hasOwnProperty('color') ? this.style.color : 0xffffff; 37 | 38 | if (color !== this.color) { 39 | this.color = color; 40 | const colorIsString = (typeof color === 'string' || color instanceof String); 41 | color = colorIsString ? parseInt('0x' + new TinyColor(color).toHex(), 16) : color; 42 | this.displayObject.color = color; 43 | } 44 | 45 | let layoutDirty = false; 46 | const newText = newProps.text || ''; 47 | 48 | if (this.displayObject.text !== newText) { 49 | this.displayObject.text = newText; 50 | layoutDirty = true; 51 | } 52 | 53 | for (let i = 0; i < textStyleKeyCount; i++) { 54 | const key = textStyleKeys[i]; 55 | const oldValue = this.displayObject[key]; 56 | const newValue = this.style.hasOwnProperty(key) ? this.style[key] : textStyleValues[key]; 57 | if (oldValue !== newValue) { 58 | this.displayObject[key] = this.style.hasOwnProperty(key) ? this.style[key] : textStyleValues[key]; 59 | layoutDirty = true; 60 | } 61 | } 62 | 63 | if (layoutDirty) { 64 | this.layoutNode.markDirty(); 65 | this.layoutDirty = true; 66 | } 67 | } 68 | 69 | measure (node, width, widthMode, height, heightMode) { 70 | const dimensions = this.displayObject.measureText(scratchPoint, width); 71 | 72 | this.sizeData.width = dimensions.x; 73 | this.sizeData.height = dimensions.y; 74 | 75 | return this.sizeData; 76 | } 77 | 78 | onLayout (x, y, width, height) { 79 | this.displayObject.pivot.x = this.anchorX * width; 80 | this.displayObject.pivot.y = this.anchorY * height; 81 | this.displayObject.width = width; 82 | this.displayObject.height = height; 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /src/elements/Text.js: -------------------------------------------------------------------------------- 1 | import { Text as PixiText, TextMetrics, TextStyle } from 'pixi.js'; 2 | import BaseElement from './BaseElement'; 3 | 4 | const textStyleKeys = { 5 | align: true, 6 | breakWords: true, 7 | dropShadow: true, 8 | dropShadowAlpha: true, 9 | dropShadowAngle: true, 10 | dropShadowBlur: true, 11 | dropShadowColor: true, 12 | dropShadowDistance: true, 13 | fill: true, 14 | fillGradientType: true, 15 | fillGradientStops: true, 16 | fontFamily: true, 17 | fontSize: true, 18 | fontStyle: true, 19 | fontVariant: true, 20 | fontWeight: true, 21 | letterSpacing: true, 22 | lineHeight: true, 23 | lineJoin: true, 24 | miterLimit: true, 25 | padding: true, 26 | stroke: true, 27 | strokeThickness: true, 28 | textBaseline: true, 29 | trim: true, 30 | whiteSpace: true, 31 | wordWrap: true, 32 | wordWrapWidth: true, 33 | leading: true 34 | }; 35 | 36 | const scratchStyle = new TextStyle(); 37 | 38 | export default class Text extends BaseElement { 39 | 40 | sizeData = { width: 0, height: 0 }; 41 | textStyle = new TextStyle(); 42 | 43 | createDisplayObject () { 44 | return new PixiText(); 45 | } 46 | 47 | applyProps (oldProps, newProps) { 48 | super.applyProps(oldProps, newProps); 49 | this.displayObject.text = newProps.text || ''; 50 | 51 | let needsUpdate = false; 52 | 53 | scratchStyle.reset(); 54 | 55 | for (let key in this.style) { 56 | if (textStyleKeys[key]) { 57 | scratchStyle[key] = this.style[key]; 58 | if (scratchStyle[key] !== this.textStyle[key]) { 59 | needsUpdate = true; 60 | break; 61 | } 62 | } 63 | } 64 | 65 | if (!needsUpdate) { 66 | return; 67 | } 68 | 69 | this.textStyle.reset(); 70 | 71 | for (let key in this.style) { 72 | if (textStyleKeys[key]) { 73 | this.textStyle[key] = this.style[key]; 74 | } 75 | } 76 | 77 | this.displayObject.style = this.textStyle; 78 | } 79 | 80 | measure (node, width, widthMode, height, heightMode) { 81 | const { text, style } = this.displayObject; 82 | const previousWordWrapWidth = style.wordWrapWidth; 83 | style.wordWrapWidth = width; 84 | const metrics = TextMetrics.measureText(text, style); 85 | style.wordWrapWidth = previousWordWrapWidth; 86 | 87 | this.sizeData.width = metrics.width; 88 | this.sizeData.height = metrics.height; 89 | 90 | return this.sizeData; 91 | } 92 | 93 | onLayout (x, y, width, height) { 94 | this.displayObject.pivot.x = this.anchorX * width; 95 | this.displayObject.pivot.y = this.anchorY * height; 96 | this.displayObject.style.wordWrapWidth = width; 97 | this.displayObject.dirty = true; 98 | } 99 | 100 | } 101 | -------------------------------------------------------------------------------- /src/elements/BackgroundImage.js: -------------------------------------------------------------------------------- 1 | import { Rectangle, Sprite, Texture } from 'pixi.js'; 2 | import RplSprite from './Sprite'; 3 | 4 | const RESIZE_MODE_STRETCH = 'stretch'; 5 | const RESIZE_MODE_COVER = 'cover'; 6 | 7 | /** 8 | * Supports 'stretch', 'cover', 'contain' 9 | **/ 10 | 11 | export default class BackgroundImage extends RplSprite { 12 | 13 | originalTexture = null; 14 | customTexture = null; 15 | _resizeMode = RESIZE_MODE_STRETCH; 16 | 17 | applyProps (oldProps, newProps) { 18 | this.resizeMode = (newProps && newProps.resizeMode) || RESIZE_MODE_STRETCH; 19 | super.applyProps(oldProps, newProps); 20 | } 21 | 22 | onLayout (x, y, width, height) { 23 | super.onLayout(x, y, width, height); 24 | this.updateTexture(this.originalTexture); 25 | } 26 | 27 | updateTexture (texture) { 28 | if (texture !== this.originalTexture) { 29 | 30 | if (this.customTexture) { 31 | this.customTexture.destroy(); 32 | } 33 | 34 | this.originalTexture = texture; 35 | this.customTexture = texture 36 | ? new Texture(texture.baseTexture, new Rectangle(), texture.orig.clone(), new Rectangle(), texture.rotate) 37 | : null; 38 | } 39 | 40 | if (!texture || this._resizeMode === RESIZE_MODE_STRETCH) { 41 | return super.updateTexture(texture); 42 | } 43 | 44 | const baseFrame = texture.frame; 45 | const tex = this.customTexture; 46 | const trim = tex.trim; 47 | 48 | tex.frame.copy(baseFrame); 49 | 50 | if (texture.trim) { 51 | trim.copy(texture.trim); 52 | } else { 53 | trim.x = 0; 54 | trim.y = 0; 55 | trim.width = baseFrame.width; 56 | trim.height = baseFrame.height; 57 | } 58 | 59 | const targetRatio = this.cachedLayout.width / this.cachedLayout.height; 60 | const baseRatio = baseFrame.width / baseFrame.height; 61 | 62 | const useWidthForScale = this._resizeMode === RESIZE_MODE_COVER 63 | ? targetRatio > baseRatio 64 | : targetRatio < baseRatio; 65 | 66 | const scale = useWidthForScale 67 | ? this.cachedLayout.width / baseFrame.width 68 | : this.cachedLayout.height / baseFrame.height; 69 | 70 | let offsetX = -(baseFrame.width - this.cachedLayout.width / scale) * 0.5; 71 | let offsetY = -(baseFrame.height - this.cachedLayout.height / scale) * 0.5; 72 | 73 | if (offsetX < 0) { 74 | tex.frame.x -= offsetX; 75 | tex.frame.width += offsetX * 2; 76 | } else { 77 | trim.x += offsetX * 0.5; 78 | trim.width -= offsetX; 79 | } 80 | 81 | if (offsetY < 0) { 82 | tex.frame.y -= offsetY; 83 | tex.frame.height += offsetY * 2; 84 | } else { 85 | trim.y += offsetY * 0.5; 86 | trim.height -= offsetY; 87 | } 88 | 89 | tex._updateUvs(); 90 | 91 | return super.updateTexture(tex); 92 | } 93 | 94 | get resizeMode () { 95 | return this._resizeMode; 96 | } 97 | 98 | set resizeMode (value) { 99 | if (this._resizeMode !== value) { 100 | this._resizeMode = value; 101 | this.updateTexture(this.originalTexture); 102 | } 103 | } 104 | 105 | } 106 | 107 | 108 | -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Stage, BitmapText, Container, Rectangle, Text, Animated } from 'react-pixi-layout'; 4 | import { Loader } from 'pixi.js'; 5 | 6 | class App extends Component { 7 | 8 | state = { 9 | toggled: false, 10 | width: window.innerWidth, 11 | height: window.innerHeight 12 | }; 13 | 14 | animatedValue = new Animated.Value(0); 15 | 16 | width = this.animatedValue.interpolate({ 17 | inputRange: [0, 1], 18 | outputRange: ['50%', '100%'] 19 | }); 20 | 21 | color = this.animatedValue.interpolate({ 22 | inputRange: [0, 1], 23 | outputRange: ['rgba(0, 255, 0, 1)', 'rgba(255, 0, 0, 1)'] 24 | }); 25 | 26 | onClick = () => { 27 | const toggled = !this.state.toggled; 28 | const toValue = toggled ? 1 : 0; 29 | 30 | Animated 31 | .timing(this.animatedValue, { toValue, duration: 500, easing: Animated.Easing.linear }) 32 | .start(); 33 | 34 | this.setState({ toggled }); 35 | }; 36 | 37 | onResize = () => this.setState({ width: window.innerWidth, height: window.innerHeight }); 38 | 39 | componentDidMount () { 40 | window.addEventListener('resize', this.onResize, false); 41 | } 42 | 43 | render () { 44 | 45 | return ( 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | ); 69 | } 70 | 71 | }; 72 | 73 | const styles = { 74 | 75 | stage: { 76 | justifyContent: 'center', 77 | alignItems: 'center' 78 | }, 79 | 80 | container: { 81 | width: '80%', 82 | height: '100%', 83 | flexDirection: 'row', 84 | alignItems: 'center' 85 | }, 86 | 87 | header: { 88 | color: 'yellow', 89 | position: 'absolute', 90 | top: 0, 91 | left: 0, 92 | right: 0, 93 | alignItems: 'center', 94 | justifyContent: 'center', 95 | padding: 32 96 | }, 97 | 98 | flexRight: { 99 | color: 'blue', 100 | height: '50%', 101 | flex: 1 102 | }, 103 | 104 | bitmapText: { 105 | color: 0x666666, 106 | font: 'arial_black', 107 | size: 32 108 | } 109 | 110 | }; 111 | 112 | // Font needs to be loaded before we can render BitmapText 113 | Loader.shared 114 | .add(`${ process.env.PUBLIC_URL }/arial_black.fnt`) 115 | .load(() => ReactDOM.render(, document.getElementById('root'))); 116 | 117 | -------------------------------------------------------------------------------- /src/elements/Container.js: -------------------------------------------------------------------------------- 1 | import { Container as PixiContainer, Point, Sprite, Texture } from 'pixi.js'; 2 | import BaseElement from './BaseElement'; 3 | 4 | const tempPoint = new Point(); 5 | 6 | class CustomContainer extends PixiContainer { 7 | 8 | _width = 0; 9 | _height = 0; 10 | 11 | isCustomContainer = true; 12 | 13 | _calculateBounds () { 14 | const b = this._bounds; 15 | b.minX = 0; 16 | b.maxX = this._width; 17 | b.minY = 0; 18 | b.maxY = this._height; 19 | } 20 | 21 | containsPoint (point) { 22 | this.worldTransform.applyInverse(point, tempPoint); 23 | 24 | const width = this._width; 25 | const height = this._height; 26 | 27 | return tempPoint.x >= 0 28 | && tempPoint.x < width 29 | && tempPoint.y >= 0 30 | && tempPoint.y < height; 31 | } 32 | 33 | get height () { 34 | return this._height; 35 | } 36 | 37 | set height (value) { 38 | this._height = value; 39 | } 40 | 41 | get width () { 42 | return this._width; 43 | } 44 | 45 | set width (value) { 46 | this._width = value; 47 | } 48 | 49 | } 50 | 51 | export default class Container extends BaseElement { 52 | 53 | children = []; 54 | clippingSprite = null; 55 | 56 | _isClippingEnabled = false; 57 | 58 | get isClippingEnabled () { 59 | return this._isClippingEnabled; 60 | } 61 | 62 | set isClippingEnabled (isClippingEnabled) { 63 | isClippingEnabled = !!isClippingEnabled; 64 | 65 | if (this._isClippingEnabled === isClippingEnabled) { 66 | return; 67 | } 68 | 69 | if (isClippingEnabled) { 70 | this.clippingSprite = new Sprite(Texture.WHITE); 71 | 72 | this.clippingSprite.width = this.bounds.width; 73 | this.clippingSprite.height = this.bounds.height; 74 | this.getChildContainer().mask = this.clippingSprite; 75 | 76 | this.getChildContainer().addChild(this.clippingSprite); 77 | } else { 78 | this.getChildContainer().mask = null; 79 | 80 | this.getChildContainer().removeChild(this.clippingSprite); 81 | 82 | this.clippingSprite = null; 83 | } 84 | 85 | this._isClippingEnabled = isClippingEnabled; 86 | } 87 | 88 | addChild (child) { 89 | this.updateMeasureFunction(true); 90 | this.layoutNode.insertChild(child.layoutNode, this.layoutNode.getChildCount()); 91 | this.getChildContainer().addChild(child.displayObject); 92 | this.children.push(child); 93 | 94 | if (this.clippingSprite) { 95 | this.getChildContainer().swapChildren(this.clippingSprite, child.displayObject); 96 | } 97 | } 98 | 99 | addChildAt (child, index) { 100 | this.layoutNode.insertChild(child.layoutNode, index); 101 | this.getChildContainer().addChildAt(child.displayObject, index); 102 | 103 | if (index === this.children.length) { 104 | this.children.push(child); 105 | 106 | if (this.clippingSprite) { 107 | this.getChildContainer().swapChildren(this.clippingSprite, child.displayObject); 108 | } 109 | } else { 110 | this.children.splice(index, 0, child); 111 | } 112 | } 113 | 114 | getChildContainer () { 115 | return this.displayObject; 116 | } 117 | 118 | removeAllChildrenRecursive () { 119 | for (let i = this.children.length - 1; i >= 0; i--) { 120 | const child = this.children[i]; 121 | this.removeChild(child); 122 | child.destroy(); 123 | } 124 | } 125 | 126 | removeChild (child) { 127 | this.getChildContainer().removeChild(child.displayObject); 128 | this.layoutNode.removeChild(child.layoutNode); 129 | 130 | const childIndex = this.children.indexOf(child); 131 | 132 | this.children.splice(childIndex, 1); 133 | this.updateMeasureFunction(this.children.length > 0); 134 | 135 | this.layoutDirty = true; 136 | } 137 | 138 | setChildIndex (child, index) { 139 | this.getChildContainer().setChildIndex(child.displayObject, index); 140 | 141 | const currentIndex = this.getChildIndex(child); 142 | 143 | this.children.splice(currentIndex, 1); 144 | this.children.splice(index, 0, child); 145 | } 146 | 147 | getChildIndex (child) { 148 | return this.children.indexOf(child); 149 | } 150 | 151 | hasChild (child) { 152 | return this.getChildIndex(child) !== -1; 153 | } 154 | 155 | applyLayout () { 156 | super.applyLayout(); 157 | 158 | const childCount = this.children.length; 159 | 160 | for (let i = 0; i < childCount; i++) { 161 | this.children[i].applyLayout(); 162 | } 163 | } 164 | 165 | applyProps (oldProps, newProps) { 166 | super.applyProps(oldProps, newProps); 167 | 168 | this.isClippingEnabled = newProps.isClippingEnabled; 169 | } 170 | 171 | destroy () { 172 | this.removeAllChildrenRecursive(); 173 | 174 | if (this.clippingSprite) { 175 | this.displayObject.removeChild(this.clippingSprite); 176 | this.clippingSprite = null; 177 | } 178 | 179 | super.destroy(); 180 | } 181 | 182 | onLayout (x, y, width, height) { 183 | this.displayObject.pivot.x = this.anchorX * width; 184 | this.displayObject.pivot.y = this.anchorY * height; 185 | 186 | if (this.displayObject.isCustomContainer) { 187 | this.displayObject.height = height; 188 | this.displayObject.width = width; 189 | } 190 | 191 | if (this.clippingSprite) { 192 | this.clippingSprite.width = width; 193 | this.clippingSprite.height = height; 194 | } 195 | } 196 | 197 | createDisplayObject () { 198 | return new CustomContainer(); 199 | } 200 | 201 | } 202 | -------------------------------------------------------------------------------- /src/elements/NineSliceSprite.js: -------------------------------------------------------------------------------- 1 | import { NineSlicePlane as PixiNineSlicePlane, Texture } from 'pixi.js'; 2 | import Container from './Container'; 3 | 4 | // Temporary (imperfect) solution to spritesheet texture bug, remove when 5 | // this is dealt with: https://github.com/pixijs/pixi.js/issues/6451 6 | 7 | class NineSlicePlane extends PixiNineSlicePlane { 8 | 9 | updateHorizontalVertices () { 10 | const vertices = this.vertices; 11 | const scale = this._getMinScale(); 12 | const trim = this.texture && this.texture.trim; 13 | const orig = this.texture && this.texture.orig; 14 | 15 | let topPadding = 0; 16 | let bottomPadding = 0; 17 | 18 | if (trim) { 19 | topPadding = trim.y; 20 | bottomPadding = orig.height - trim.height - trim.y; 21 | } 22 | 23 | vertices[1] = vertices[3] = vertices[5] = vertices[7] = topPadding * scale; 24 | vertices[9] = vertices[11] = vertices[13] = vertices[15] = this._topHeight * scale; 25 | vertices[17] = vertices[19] = vertices[21] = vertices[23] = this._height - (this._bottomHeight * scale); 26 | vertices[25] = vertices[27] = vertices[29] = vertices[31] = this._height - bottomPadding; 27 | } 28 | 29 | updateVerticalVertices () { 30 | const vertices = this.vertices; 31 | const scale = this._getMinScale(); 32 | const trim = this.texture && this.texture.trim; 33 | const orig = this.texture && this.texture.orig; 34 | 35 | let leftPadding = 0; 36 | let rightPadding = 0; 37 | 38 | if (trim) { 39 | leftPadding = trim.x; 40 | rightPadding = orig.width - trim.width - trim.x; 41 | } 42 | 43 | vertices[0] = vertices[8] = vertices[16] = vertices[24] = leftPadding * scale; 44 | vertices[2] = vertices[10] = vertices[18] = vertices[26] = this._leftWidth * scale; 45 | vertices[4] = vertices[12] = vertices[20] = vertices[28] = this._width - (this._rightWidth * scale); 46 | vertices[6] = vertices[14] = vertices[22] = vertices[30] = this._width - leftPadding; 47 | } 48 | 49 | _refresh () { 50 | const texture = this.texture; 51 | 52 | const uvs = this.geometry.buffers[1].data; 53 | 54 | this._origWidth = texture.orig.width; 55 | this._origHeight = texture.orig.height; 56 | 57 | const _uvw = 1.0 / this._origWidth; 58 | const _uvh = 1.0 / this._origHeight; 59 | 60 | let topPadding = 0; 61 | let bottomPadding = 0; 62 | let leftPadding = 0; 63 | let rightPadding = 0; 64 | 65 | if (texture.trim) { 66 | topPadding = _uvh * texture.trim.y; 67 | bottomPadding = (this._origHeight - texture.trim.height - texture.trim.y) * _uvh; 68 | leftPadding = _uvw * texture.trim.x; 69 | rightPadding = (this._origWidth - texture.trim.width - texture.trim.x) * _uvw; 70 | } 71 | 72 | uvs[0] = uvs[8] = uvs[16] = uvs[24] = leftPadding; 73 | uvs[1] = uvs[3] = uvs[5] = uvs[7] = topPadding; 74 | uvs[6] = uvs[14] = uvs[22] = uvs[30] = 1 - rightPadding; 75 | uvs[25] = uvs[27] = uvs[29] = uvs[31] = 1 - bottomPadding; 76 | 77 | uvs[2] = uvs[10] = uvs[18] = uvs[26] = _uvw * this._leftWidth + leftPadding; 78 | uvs[4] = uvs[12] = uvs[20] = uvs[28] = 1 - (_uvw * this._rightWidth) - rightPadding; 79 | uvs[9] = uvs[11] = uvs[13] = uvs[15] = _uvh * this._topHeight - topPadding; 80 | uvs[17] = uvs[19] = uvs[21] = uvs[23] = 1 - (_uvh * this._bottomHeight) - bottomPadding; 81 | 82 | this.updateHorizontalVertices(); 83 | this.updateVerticalVertices(); 84 | 85 | this.geometry.buffers[0].update(); 86 | this.geometry.buffers[1].update(); 87 | } 88 | 89 | setBorders (top, right, bottom, left) { 90 | this._topHeight = top; 91 | this._rightWidth = right; 92 | this._bottomHeight = bottom; 93 | this._leftWidth = left; 94 | this._refresh(); 95 | } 96 | 97 | } 98 | 99 | export default class NineSliceSprite extends Container { 100 | 101 | _textureRef = null; 102 | 103 | createDisplayObject () { 104 | return new NineSlicePlane(Texture.EMPTY); 105 | } 106 | 107 | applyProps (oldProps, newProps) { 108 | super.applyProps(oldProps, newProps); 109 | 110 | let texture; 111 | 112 | const textureRef = newProps.texture || this.style.texture || null; 113 | 114 | if (this._textureRef !== textureRef) { 115 | 116 | this._textureRef = textureRef; 117 | 118 | if (textureRef) { 119 | texture = (typeof textureRef === 'string' || textureRef instanceof String) 120 | ? Texture.from(textureRef) 121 | : textureRef; 122 | } else { 123 | texture = Texture.NONE; 124 | } 125 | 126 | this.displayObject.texture = texture; 127 | } else { 128 | texture = this.displayObject.texture; 129 | } 130 | 131 | const height = texture ? texture.height : 0; 132 | const width = texture ? texture.width : 0; 133 | 134 | const { 135 | bottomHeight = height * 0.5, 136 | rightWidth = width * 0.5, 137 | topHeight = height * 0.5, 138 | leftWidth = width * 0.5 139 | } = this.style; 140 | 141 | const dO = this.displayObject; 142 | const needsUpdate = dO.topHeight !== topHeight || dO.bottomHeight !== bottomHeight || dO.leftWidth !== leftWidth || dO.rightWidth !== rightWidth; 143 | 144 | if (needsUpdate) { 145 | this.displayObject.setBorders(topHeight, rightWidth, bottomHeight, leftWidth); 146 | } 147 | } 148 | 149 | onLayout (x, y, width, height) { 150 | super.onLayout(x, y, width, height); 151 | 152 | if (this.displayObject.texture) { 153 | this.displayObject.width = width; 154 | this.displayObject.height = height; 155 | } 156 | } 157 | 158 | } 159 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Animated from '@lunarraid/animated'; 2 | import Easing from '@lunarraid/animated/lib/Easing'; 3 | 4 | import React from 'react'; 5 | import ReactFiberReconciler from 'react-reconciler'; 6 | 7 | import BaseElement from './elements/BaseElement'; 8 | import BackgroundContainerElement, { BackgroundContainerResizeModes } from './elements/BackgroundContainer'; 9 | import BackgroundImageElement from './elements/BackgroundImage'; 10 | import BitmapTextElement from './elements/BitmapText'; 11 | import ContainerElement from './elements/Container'; 12 | import GraphicsElement from './elements/Graphics'; 13 | import NineSliceSpriteElement from './elements/NineSliceSprite'; 14 | import RectangleElement from './elements/Rectangle'; 15 | import MaskContainerElement from './elements/MaskContainer'; 16 | import ScrollContainerElement from './elements/ScrollContainer'; 17 | import SpriteElement from './elements/Sprite'; 18 | import TextElement from './elements/Text'; 19 | import TextInputElement from './elements/TextInput'; 20 | import TilingSpriteElement from './elements/TilingSprite'; 21 | import RootContainer from './elements/RootContainer'; 22 | 23 | import Stage from './Stage'; 24 | import mergeStyles from './mergeStyles'; 25 | 26 | const UPDATE_SIGNAL = {}; 27 | const performance = window.performance || window.msPerformance || window.webkitPerformance; 28 | const _registeredElements = {}; 29 | 30 | const PixiContext = React.createContext(); 31 | 32 | function appendChild (parentInstance, child) { 33 | const childExists = parentInstance.hasChild(child); 34 | 35 | if (childExists) { 36 | parentInstance.removeChild(child); 37 | } 38 | 39 | parentInstance.addChild(child); 40 | } 41 | 42 | function removeChild (parentInstance, child) { 43 | parentInstance.removeChild(child); 44 | child.destroy(); 45 | } 46 | 47 | function insertBefore (parentInstance, child, beforeChild) { 48 | if (child === beforeChild) { 49 | throw new Error('ReactPixiLayout cannot insert node before itself'); 50 | } 51 | 52 | const childExists = parentInstance.hasChild(child); 53 | 54 | if (childExists) { 55 | parentInstance.removeChild(child); 56 | } 57 | 58 | const index = parentInstance.getChildIndex(beforeChild); 59 | 60 | parentInstance.addChildAt(child, index); 61 | } 62 | 63 | function commitUpdate (instance, updatePayload, type, oldProps, newProps, internalInstanceHandle) { 64 | instance.applyProps(oldProps, newProps); 65 | instance.displayObject.___props = newProps; 66 | } 67 | 68 | const ReactPixiLayout = ReactFiberReconciler({ 69 | 70 | supportsMutation: true, 71 | isPrimaryRenderer: false, 72 | 73 | appendInitialChild: appendChild, 74 | 75 | clearContainer: function (container) { 76 | container.removeAllChildrenRecursive(); 77 | }, 78 | 79 | createInstance: function (type, props, internalInstanceHandle, hostContext) { 80 | const ctor = _registeredElements[type]; 81 | 82 | if (!ctor) { 83 | throw new Error(`ReactPixiLayout does not support the type: ${ type }`); 84 | } 85 | 86 | const instance = new ctor(props, hostContext.root); 87 | instance.applyProps({}, props); 88 | instance.displayObject.___props = props; 89 | return instance; 90 | }, 91 | 92 | createTextInstance: function (text, rootContainerInstance, internalInstanceHandle) { 93 | throw new Error('ReactPixiLayout does not support text instances. Use Text component instead.'); 94 | }, 95 | 96 | finalizeInitialChildren: function (pixiElement, type, props, rootContainerInstance) { 97 | if (pixiElement === rootContainerInstance.publicInstance) { 98 | rootContainerInstance.updateLayout(); 99 | } 100 | 101 | return false; 102 | }, 103 | 104 | getChildHostContext: function (parentHostContext, type) { 105 | return parentHostContext; 106 | }, 107 | 108 | getRootHostContext: function (rootContainerInstance) { 109 | return { root: rootContainerInstance }; 110 | }, 111 | 112 | getPublicInstance: function (inst) { 113 | return inst.publicInstance; 114 | }, 115 | 116 | now: function () { 117 | return performance.now(); 118 | }, 119 | 120 | prepareForCommit: function () { 121 | return null; 122 | }, 123 | 124 | prepareUpdate: function (pixiElement, type, oldProps, newProps, rootContainerInstance, hostContext) { 125 | return UPDATE_SIGNAL; 126 | }, 127 | 128 | resetAfterCommit: function (container) { 129 | // Noop 130 | }, 131 | 132 | resetTextContent: function (pixiElement) { 133 | // Noop 134 | }, 135 | 136 | shouldDeprioritizeSubtree: function (type, props) { 137 | const isAlphaVisible = props.alpha === undefined || props.alpha > 0; 138 | const isRenderable = props.renderable === undefined || props.renderable === true; 139 | const isVisible = props.visible === undefined || props.visible === true; 140 | 141 | return !(isAlphaVisible && isRenderable && isVisible); 142 | }, 143 | 144 | shouldSetTextContent: function (type, props) { 145 | return false; 146 | }, 147 | 148 | useSyncScheduling: true, 149 | 150 | 151 | appendChild: appendChild, 152 | appendChildToContainer: appendChild, 153 | 154 | insertBefore: insertBefore, 155 | insertInContainerBefore: insertBefore, 156 | 157 | removeChild: removeChild, 158 | removeChildFromContainer: removeChild, 159 | 160 | commitTextUpdate: function (textInstance, oldText, newText) { 161 | // Noop 162 | }, 163 | 164 | commitUpdate: commitUpdate 165 | 166 | }); 167 | 168 | export function registerElement (name, element) { 169 | _registeredElements[name] = element; 170 | return name; 171 | } 172 | 173 | export async function render (element, target, callback) { 174 | const container = target.__rootReactContainer || new RootContainer(); 175 | const node = target.__rootReactNode || ReactPixiLayout.createContainer(container); 176 | 177 | ReactPixiLayout.injectIntoDevTools({ 178 | findFiberByHostInstance: ReactPixiLayout.findFiberByHostInstance, 179 | bundleType: 1, 180 | version: '16.8.6', 181 | rendererPackageName: 'react-pixi-layout' 182 | }); 183 | 184 | target.__rootReactNode = node; 185 | target.__rootReactContainer = container; 186 | 187 | if (container.displayObject.parent !== target) { 188 | target.addChild(container.displayObject); 189 | } 190 | 191 | await new Promise((resolve) => { 192 | ReactPixiLayout.updateContainer(element, node, null, resolve); 193 | }); 194 | 195 | container.updateLayout() 196 | 197 | callback(); 198 | } 199 | 200 | 201 | export const Container = 'Container'; 202 | export const Text = 'Text'; 203 | export const TextInput = 'TextInput'; 204 | export const BitmapText = 'BitmapText'; 205 | export const ScrollContainer = 'ScrollContainer'; 206 | export const Sprite = 'Sprite'; 207 | export const BackgroundContainer = 'BackgroundContainer'; 208 | export const BackgroundImage = 'BackgroundImage'; 209 | export const TilingSprite = 'TilingSprite'; 210 | export const NineSliceSprite = 'NineSliceSprite'; 211 | export const Graphics = 'Graphics'; 212 | export const Rectangle = 'Rectangle'; 213 | export const MaskContainer = 'MaskContainer'; 214 | 215 | registerElement(Container, ContainerElement); 216 | registerElement(Text, TextElement); 217 | registerElement(TextInput, TextInputElement); 218 | registerElement(BitmapText, BitmapTextElement); 219 | registerElement(ScrollContainer, ScrollContainerElement); 220 | registerElement(Sprite, SpriteElement); 221 | registerElement(BackgroundContainer, BackgroundContainerElement); 222 | registerElement(BackgroundImage, BackgroundImageElement); 223 | registerElement(TilingSprite, TilingSpriteElement); 224 | registerElement(NineSliceSprite, NineSliceSpriteElement); 225 | registerElement(Graphics, GraphicsElement); 226 | registerElement(Rectangle, RectangleElement); 227 | registerElement(MaskContainer, MaskContainerElement); 228 | 229 | const animatedExport = { 230 | BackgroundContainer: Animated.createAnimatedComponent(BackgroundContainer), 231 | Container: Animated.createAnimatedComponent(Container), 232 | Text: Animated.createAnimatedComponent(Text), 233 | TextInput: Animated.createAnimatedComponent(TextInput), 234 | BitmapText: Animated.createAnimatedComponent(BitmapText), 235 | ScrollContainer: Animated.createAnimatedComponent(ScrollContainer), 236 | Sprite: Animated.createAnimatedComponent(Sprite), 237 | BackgroundImage: Animated.createAnimatedComponent(Sprite), 238 | TilingSprite: Animated.createAnimatedComponent(TilingSprite), 239 | NineSliceSprite: Animated.createAnimatedComponent(NineSliceSprite), 240 | Graphics: Animated.createAnimatedComponent(Graphics), 241 | Rectangle: Animated.createAnimatedComponent(Rectangle), 242 | MaskContainer: Animated.createAnimatedComponent(MaskContainer), 243 | Easing, 244 | ...Animated 245 | }; 246 | 247 | const Elements = { 248 | BaseElement, 249 | BackgroundContainerElement, 250 | BackgroundImageElement, 251 | BitmapTextElement, 252 | ContainerElement, 253 | GraphicsElement, 254 | NineSliceSpriteElement, 255 | RectangleElement, 256 | MaskContainerElement, 257 | ScrollContainerElement, 258 | SpriteElement, 259 | TextElement, 260 | TextInputElement, 261 | TilingSpriteElement 262 | }; 263 | 264 | export { 265 | Elements, 266 | PixiContext, 267 | ReactPixiLayout, 268 | Stage, 269 | animatedExport as Animated, 270 | Easing, 271 | mergeStyles, 272 | BackgroundContainerResizeModes 273 | }; 274 | -------------------------------------------------------------------------------- /src/elements/Sprite.js: -------------------------------------------------------------------------------- 1 | import { Matrix, Point, settings, Sprite as PixiSprite, Texture } from 'pixi.js'; 2 | import * as Yoga from 'typeflex'; 3 | import Container from './Container'; 4 | 5 | const SCRATCH_MATRIX = new Matrix(); 6 | const SCRATCH_POINT = new Point(); 7 | 8 | const { round } = Math; 9 | 10 | class SpriteContainer extends PixiSprite { 11 | 12 | calculateVertices () { 13 | const texture = this._texture; 14 | 15 | if (this._transformID === this.transform._worldID && this._textureID === texture._updateID) { 16 | return; 17 | } 18 | 19 | // update texture UV here, because base texture can be changed without calling `_onTextureUpdate` 20 | if (this._textureID !== texture._updateID) { 21 | this.uvs = this._texture._uvs.uvsFloat32; 22 | } 23 | 24 | this._transformID = this.transform._worldID; 25 | this._textureID = texture._updateID; 26 | 27 | // set the vertex data 28 | 29 | const { a, b, c, d, tx, ty } = this.transform.worldTransform; 30 | const vertexData = this.vertexData; 31 | const { trim, orig } = texture; 32 | const anchor = this._anchor; 33 | 34 | let w0 = 0; 35 | let w1 = 0; 36 | let h0 = 0; 37 | let h1 = 0; 38 | 39 | if (trim) { 40 | const scaleX = this._width / orig.width; 41 | const scaleY = this._height / orig.height; 42 | 43 | // if the sprite is trimmed and is not a tilingsprite then we need to add the extra 44 | // space before transforming the sprite coords. 45 | w1 = (trim.x * scaleX) - (anchor._x * this._width); 46 | w0 = w1 + trim.width * scaleX; 47 | 48 | h1 = (trim.y * scaleY) - (anchor._y * this._height); 49 | h0 = h1 + (trim.height * scaleY); 50 | } else { 51 | w1 = -anchor._x * this._width; 52 | w0 = w1 + this._width; 53 | 54 | h1 = -anchor._y * this._height; 55 | h0 = h1 + this._height; 56 | } 57 | 58 | // xy 59 | vertexData[0] = (a * w1) + (c * h1) + tx; 60 | vertexData[1] = (d * h1) + (b * w1) + ty; 61 | 62 | // xy 63 | vertexData[2] = (a * w0) + (c * h1) + tx; 64 | vertexData[3] = (d * h1) + (b * w0) + ty; 65 | 66 | // xy 67 | vertexData[4] = (a * w0) + (c * h0) + tx; 68 | vertexData[5] = (d * h0) + (b * w0) + ty; 69 | 70 | // xy 71 | vertexData[6] = (a * w1) + (c * h0) + tx; 72 | vertexData[7] = (d * h0) + (b * w1) + ty; 73 | 74 | if (this._roundPixels) 75 | { 76 | const resolution = settings.RESOLUTION; 77 | 78 | for (let i = 0; i < vertexData.length; ++i) 79 | { 80 | vertexData[i] = round((vertexData[i] * resolution | 0) / resolution); 81 | } 82 | } 83 | } 84 | 85 | calculateTrimmedVertices () { 86 | if (!this.vertexTrimmedData) { 87 | this.vertexTrimmedData = new Float32Array(8); 88 | } else if (this._transformTrimmedID === this.transform._worldID && this._textureTrimmedID === this._texture._updateID) { 89 | return; 90 | } 91 | 92 | this._transformTrimmedID = this.transform._worldID; 93 | this._textureTrimmedID = this._texture._updateID; 94 | 95 | // lets do some special trim code! 96 | const texture = this._texture; 97 | const vertexData = this.vertexTrimmedData; 98 | const orig = texture.orig; 99 | const anchor = this._anchor; 100 | 101 | // lets calculate the new untrimmed bounds.. 102 | const { a, b, c, d, tx, ty } = this.transform.worldTransform; 103 | 104 | const w1 = -anchor._x * this._width; 105 | const w0 = w1 + this._width; 106 | 107 | const h1 = -anchor._y * this._height; 108 | const h0 = h1 + this._height; 109 | 110 | // xy 111 | vertexData[0] = (a * w1) + (c * h1) + tx; 112 | vertexData[1] = (d * h1) + (b * w1) + ty; 113 | 114 | // xy 115 | vertexData[2] = (a * w0) + (c * h1) + tx; 116 | vertexData[3] = (d * h1) + (b * w0) + ty; 117 | 118 | // xy 119 | vertexData[4] = (a * w0) + (c * h0) + tx; 120 | vertexData[5] = (d * h0) + (b * w0) + ty; 121 | 122 | // xy 123 | vertexData[6] = (a * w1) + (c * h0) + tx; 124 | vertexData[7] = (d * h0) + (b * w1) + ty; 125 | } 126 | 127 | containsPoint (point) { 128 | this.worldTransform.applyInverse(point, SCRATCH_POINT); 129 | 130 | const width = this._width; 131 | const height = this._height; 132 | const x1 = -width * this.anchor.x; 133 | let y1 = 0; 134 | 135 | if (SCRATCH_POINT.x >= x1 && SCRATCH_POINT.x < x1 + width) { 136 | y1 = -height * this.anchor.y; 137 | 138 | if (SCRATCH_POINT.y >= y1 && SCRATCH_POINT.y < y1 + height) { 139 | return true; 140 | } 141 | } 142 | 143 | return false; 144 | } 145 | 146 | getLocalBounds (rect) { 147 | // we can do a fast local bounds if the sprite has no children! 148 | if (this.children.length === 0) { 149 | this._bounds.minX = this._width * -this._anchor._x; 150 | this._bounds.minY = this._height * -this._anchor._y; 151 | this._bounds.maxX = this._width * (1 - this._anchor._x); 152 | this._bounds.maxY = this._height * (1 - this._anchor._y); 153 | 154 | if (!rect) { 155 | if (!this._localBoundsRect) { 156 | this._localBoundsRect = new Rectangle(); 157 | } 158 | 159 | rect = this._localBoundsRect; 160 | } 161 | 162 | return this._bounds.getRectangle(rect); 163 | } 164 | 165 | return super.getLocalBounds(rect); 166 | } 167 | 168 | _onTextureUpdate () { 169 | this._textureID = -1; 170 | this._textureTrimmedID = -1; 171 | this._cachedTint = 0xffffff; 172 | 173 | // if (this._height === 0) { 174 | // this._height = this.texture.orig.height; 175 | // } 176 | 177 | // if (this._width === 0) { 178 | // this._width = this.texture.orig.width; 179 | // } 180 | } 181 | 182 | get height () { 183 | return this._height; 184 | } 185 | 186 | set height (value) { 187 | this._height = value; 188 | this._transformID = -1; 189 | } 190 | 191 | get width () { 192 | return this._width; 193 | } 194 | 195 | set width (value) { 196 | this._width = value; 197 | this._transformID = -1; 198 | } 199 | 200 | } 201 | 202 | export default class Sprite extends Container { 203 | 204 | sizeData = { width: 0, height: 0 }; 205 | _textureRef = null; 206 | 207 | createDisplayObject () { 208 | return new SpriteContainer(Texture.WHITE); 209 | } 210 | 211 | applyProps (oldProps, newProps) { 212 | super.applyProps(oldProps, newProps); 213 | 214 | const textureRef = newProps.texture || this.style.texture || null; 215 | 216 | if (this._textureRef !== textureRef) { 217 | 218 | this._textureRef = textureRef; 219 | let texture; 220 | 221 | if (textureRef) { 222 | texture = (typeof textureRef === 'string' || textureRef instanceof String) 223 | ? Texture.from(textureRef) 224 | : textureRef; 225 | } else { 226 | texture = Texture.WHITE; 227 | } 228 | 229 | this.updateTexture(texture); 230 | } 231 | } 232 | 233 | updateTexture (texture) { 234 | if (texture && !texture.baseTexture.valid) { 235 | texture.once('update', () => this.displayObject && this.updateTexture(this.displayObject.texture)); 236 | } 237 | 238 | this.displayObject.texture = texture; 239 | this.displayObject.pivot.x = texture ? texture.orig.width * this.anchorX : 0; 240 | this.displayObject.pivot.y = texture ? texture.orig.height * this.anchorY : 0; 241 | 242 | // Due to custom measure function, we have to manually flag 243 | // dirty when we update the texture 244 | if (this.children.length === 0) { 245 | this.layoutNode.markDirty(); 246 | } 247 | 248 | this.layoutDirty = true; 249 | } 250 | 251 | measure (node, width, widthMode, height, heightMode) { 252 | 253 | const texture = this.displayObject.texture; 254 | 255 | if (!texture || !texture.orig || texture.orig.width === 0 || texture.orig.height === 0) { 256 | this.sizeData.width = this.sizeData.height = 0; 257 | return this.sizeData; 258 | } 259 | 260 | let calculatedWidth = texture.orig.width; 261 | let calculatedHeight = texture.orig.height; 262 | const scale = calculatedWidth / calculatedHeight; 263 | 264 | if (widthMode === Yoga.MEASURE_MODE_AT_MOST) { 265 | calculatedWidth = width > calculatedWidth ? calculatedWidth : width; 266 | calculatedHeight = calculatedWidth / scale; 267 | } 268 | 269 | if (heightMode === Yoga.MEASURE_MODE_AT_MOST) { 270 | calculatedHeight = height > calculatedHeight ? calculatedHeight : height; 271 | calculatedWidth = calculatedHeight * scale; 272 | } 273 | 274 | if (widthMode === Yoga.MEASURE_MODE_EXACTLY) { 275 | calculatedWidth = width; 276 | calculatedHeight = heightMode !== Yoga.MEASURE_MODE_EXACTLY ? calculatedWidth / scale : height; 277 | } 278 | 279 | if (heightMode === Yoga.MEASURE_MODE_EXACTLY) { 280 | calculatedHeight = height; 281 | calculatedWidth = widthMode !== Yoga.MEASURE_MODE_EXACTLY ? calculatedHeight * scale : width; 282 | } 283 | 284 | this.sizeData.width = calculatedWidth; 285 | this.sizeData.height = calculatedHeight; 286 | 287 | return this.sizeData; 288 | } 289 | 290 | onLayout (x, y, width, height) { 291 | this.displayObject.pivot.x = this.anchorX * width; 292 | this.displayObject.pivot.y = this.anchorY * height; 293 | this.displayObject.height = height; 294 | this.displayObject.width = width; 295 | 296 | if (this.clippingSprite) { 297 | this.clippingSprite.width = width; 298 | this.clippingSprite.height = height; 299 | } 300 | } 301 | 302 | } 303 | -------------------------------------------------------------------------------- /src/elements/BackgroundContainer.js: -------------------------------------------------------------------------------- 1 | import { Container as PixiContainer, NineSlicePlane, Rectangle, Point, Sprite, Texture } from 'pixi.js'; 2 | 3 | import TinyColor from '../util/TinyColor'; 4 | 5 | import Container from './Container'; 6 | 7 | const emptyObject = {}; 8 | 9 | export const BackgroundContainerResizeModes = { 10 | CONTAIN: 'contain', 11 | COVER: 'cover', 12 | NINE_SLICE: 'nineSlice', 13 | STRETCH: 'stretch' 14 | }; 15 | 16 | export default class BackgroundContainer extends Container { 17 | 18 | background = null; 19 | childContainer = null; 20 | originalTexture = null; 21 | modifiedTexture = null; 22 | textureScale = 1; 23 | 24 | _resizeMode = null; 25 | 26 | constructor (props, root) { 27 | super(props, root); 28 | 29 | const { texture = Texture.WHITE } = props; 30 | 31 | if (typeof texture === 'string' || texture instanceof String) { 32 | this.originalTexture = Texture.from(texture); 33 | } else { 34 | this.originalTexture = texture; 35 | } 36 | 37 | this.childContainer = new PixiContainer(); 38 | 39 | this.displayObject.addChild(this.childContainer); 40 | } 41 | 42 | getChildContainer () { 43 | return this.childContainer; 44 | } 45 | 46 | destroy () { 47 | this.displayObject.removeChild(this.background); 48 | this.displayObject.removeChild(this.childContainer); 49 | 50 | super.destroy(); 51 | 52 | // Don't null out these references until after destroying 53 | // because childContainer is used in getChildContainer() 54 | // as part of the destroy flow to clean up current state 55 | 56 | this.background = null; 57 | this.childContainer = null; 58 | } 59 | 60 | get resizeMode () { 61 | return this._resizeMode; 62 | } 63 | 64 | set resizeMode (value) { 65 | if (this._resizeMode !== value) { 66 | const is9Slice = value === BackgroundContainerResizeModes.NINE_SLICE; 67 | const was9Slice = this._resizeMode === BackgroundContainerResizeModes.NINE_SLICE; 68 | 69 | this._resizeMode = value; 70 | 71 | if (this.background && is9Slice !== was9Slice) { 72 | this.displayObject.removeChild(this.background); 73 | 74 | this.background.destroy(); 75 | 76 | this.background = null; 77 | } 78 | 79 | if (!this.background) { 80 | this.background = value === BackgroundContainerResizeModes.NINE_SLICE 81 | ? new NineSlicePlane(Texture.WHITE) 82 | : new Sprite(Texture.WHITE); 83 | } 84 | 85 | this.displayObject.addChildAt(this.background, 0); 86 | 87 | this.updateTexture(this.originalTexture); 88 | this.updateColor(this.style); 89 | } 90 | } 91 | 92 | onLayout (x, y, width, height) { 93 | super.onLayout(x, y, width, height); 94 | 95 | this.updateTexture(this.originalTexture); 96 | this.updateColor(this.style); 97 | } 98 | 99 | applyProps (oldProps, newProps = emptyObject) { 100 | const oldStyle = this.style || emptyObject; 101 | 102 | super.applyProps(oldProps, newProps); 103 | 104 | const { resizeMode = this.style.resizeMode, texture = this.style.texture, textureScale } = newProps; 105 | const previousTexture = this.originalTexture; 106 | 107 | let needsUpdate = true; 108 | 109 | if (!texture) { 110 | this.originalTexture = Texture.WHITE; 111 | } else if (typeof texture === 'string' || texture instanceof String) { 112 | this.originalTexture = Texture.from(texture); 113 | } else { 114 | this.originalTexture = texture; 115 | } 116 | 117 | if (this.originalTexture && !this.originalTexture.baseTexture.valid) { 118 | needsUpdate = false; 119 | 120 | this.originalTexture.once('update', () => this.displayObject && this.updateTexture(this.originalTexture)); 121 | } 122 | 123 | this.textureScale = textureScale || 1; 124 | this.resizeMode = resizeMode || BackgroundContainerResizeModes.STRETCH; 125 | 126 | if (this.background) { 127 | if (this.resizeMode === BackgroundContainerResizeModes.NINE_SLICE) { 128 | const { topHeight, rightWidth, bottomHeight, leftWidth } = newProps; 129 | const { frame } = this.originalTexture; 130 | 131 | this.background.topHeight = topHeight || frame.height * 0.5; 132 | this.background.rightWidth = rightWidth || frame.width * 0.5; 133 | this.background.bottomHeight = bottomHeight || frame.height * 0.5; 134 | this.background.leftWidth = leftWidth || frame.width * 0.5; 135 | } 136 | 137 | const newStyle = this.style || emptyObject; 138 | const alphaChanged = oldStyle.alpha !== newStyle.alpha; 139 | const backgroundColorChanged = oldStyle.backgroundColor !== newStyle.backgroundColor; 140 | const tintChanged = oldStyle.tint !== newStyle.tint; 141 | 142 | if (alphaChanged || backgroundColorChanged || tintChanged) { 143 | this.updateColor(newStyle || emptyObject); 144 | } 145 | } 146 | 147 | if (needsUpdate && this.originalTexture && this.originalTexture !== previousTexture) { 148 | this.updateTexture(this.originalTexture); 149 | } 150 | } 151 | 152 | updateColor (style) { 153 | if (style.tint !== undefined) { 154 | this.background.alpha = style.alpha !== undefined ? style.alpha : 1; 155 | this.background.tint = style.tint; 156 | } else if (style.backgroundColor !== undefined) { 157 | const color = new TinyColor(style.backgroundColor); 158 | 159 | this.background.alpha = color.getAlpha(); 160 | this.background.tint = parseInt('0x' + color.toHex(), 16); 161 | } else { 162 | this.background.alpha = 1; 163 | this.background.tint = 0xffffff; 164 | } 165 | } 166 | 167 | updateTexture (requestedTexture) { 168 | if (this.originalTexture !== requestedTexture) { 169 | if (this.modifiedTexture) { 170 | this.modifiedTexture.destroy(); 171 | 172 | this.modifiedTexture = null; 173 | } 174 | 175 | this.originalTexture = requestedTexture; 176 | } 177 | 178 | const { width: layoutWidth, height: layoutHeight } = this.cachedLayout; 179 | 180 | if (!layoutWidth || !layoutHeight) { 181 | // Don't draw, it's zero size (and this fixes 9-slicing) 182 | 183 | this.background.texture = Texture.EMPTY; 184 | 185 | return; 186 | } 187 | 188 | if (this.resizeMode === BackgroundContainerResizeModes.STRETCH) { 189 | // Just shove the texture in at the full width/height 190 | 191 | this.background.texture = requestedTexture; 192 | this.background.width = layoutWidth; 193 | this.background.height = layoutHeight; 194 | 195 | return; 196 | } 197 | 198 | const { frame: requestedFrame } = requestedTexture; 199 | 200 | const modifiedTexture = new Texture( 201 | requestedTexture.baseTexture, 202 | requestedTexture.frame.clone(), 203 | requestedTexture.orig.clone(), 204 | requestedTexture.trim 205 | ? requestedTexture.trim.clone() 206 | : new Rectangle(0, 0, requestedFrame.width, requestedFrame.height), 207 | requestedTexture.rotate, 208 | requestedTexture.anchor 209 | ? requestedTexture.anchor.clone() 210 | : new Point(0, 0) 211 | ); 212 | 213 | if (this.resizeMode === BackgroundContainerResizeModes.NINE_SLICE) { 214 | this.background.texture = modifiedTexture; 215 | this.background.scale = new Point(this.textureScale, this.textureScale); 216 | this.background.width = layoutWidth / this.textureScale; 217 | this.background.height = layoutHeight / this.textureScale; 218 | 219 | return; 220 | } 221 | 222 | const { frame: modifiedFrame, trim: modifiedTrim } = modifiedTexture; 223 | 224 | const isContain = this.resizeMode === BackgroundContainerResizeModes.CONTAIN; 225 | const layoutRatio = layoutWidth / layoutHeight; 226 | const trimmedRatio = modifiedFrame.width / modifiedFrame.height; 227 | const useWidth = isContain ? layoutRatio < trimmedRatio : layoutRatio > trimmedRatio; 228 | const scale = useWidth ? layoutWidth / modifiedFrame.width : layoutHeight / modifiedFrame.height; 229 | 230 | const scaledLayoutWidth = layoutWidth / scale; 231 | const scaledLayoutHeight = layoutHeight / scale; 232 | const offsetX = (scaledLayoutWidth - modifiedFrame.width) * 0.5; 233 | const offsetY = (scaledLayoutHeight - modifiedFrame.height) * 0.5; 234 | 235 | if (isContain) { 236 | const widthRatio = modifiedTrim.width / (modifiedTrim.width + offsetX * 2); 237 | const heightRatio = modifiedTrim.height / (modifiedTrim.height + offsetY * 2); 238 | 239 | modifiedTrim.x += modifiedTrim.width * (1 - widthRatio) * 0.5; 240 | modifiedTrim.y += modifiedTrim.height * (1 - heightRatio) * 0.5; 241 | modifiedTrim.width *= widthRatio; 242 | modifiedTrim.height *= heightRatio; 243 | } else { 244 | modifiedFrame.x -= offsetX; 245 | modifiedFrame.y -= offsetY; 246 | modifiedFrame.width += offsetX * 2; 247 | modifiedFrame.height += offsetY * 2; 248 | 249 | // TODO: Fix resize mode cover with source trim 250 | 251 | // if (offsetX < 0 && modifiedTrim.x) { 252 | // const spill = modifiedTrim.x + offsetX; 253 | // 254 | // modifiedFrame.x -= spill; 255 | // modifiedFrame.width += spill * 2; 256 | // 257 | // // modifiedTrim.x -= spill; 258 | // // modifiedTrim.width += spill * 2; 259 | // } 260 | // 261 | // if (offsetY < 0 && modifiedTrim.y) { 262 | // const spill = modifiedTrim.y + offsetY; 263 | // 264 | // modifiedFrame.y -= spill; 265 | // modifiedFrame.height += spill * 2; 266 | // 267 | // // modifiedTrim.y = 0; 268 | // // modifiedTrim.height = (modifiedTrim.height + modifiedTrim.y * 2) * 2; 269 | // } 270 | } 271 | 272 | modifiedTexture.updateUvs(); 273 | 274 | this.modifiedTexture = modifiedTexture; 275 | this.background.texture = modifiedTexture; 276 | this.background.width = layoutWidth; 277 | this.background.height = layoutHeight; 278 | } 279 | 280 | } 281 | -------------------------------------------------------------------------------- /src/elements/BaseElement.js: -------------------------------------------------------------------------------- 1 | import { BLEND_MODES, Rectangle } from 'pixi.js'; 2 | import * as Yoga from 'typeflex'; 3 | 4 | // import applyLayoutProperties, { applyDefaultLayoutProperties } from 'react-pixi-layout/applyLayoutProperties'; 5 | import applyLayoutProperties from '../applyLayoutProperties'; 6 | import mergeStyles from '../mergeStyles'; 7 | 8 | const interactiveProps = { 9 | pointerdown: 'onDown', 10 | pointermove: 'onMove', 11 | pointerup: 'onUp', 12 | pointerupoutside: 'onUpOutside', 13 | pointertap : 'onClick', 14 | pointerout: 'onOut', 15 | pointerover: 'onOver', 16 | pointercancel: 'onCancel' 17 | }; 18 | 19 | const interactivePropList = Object.keys(interactiveProps); 20 | const interactivePropCount = interactivePropList.length; 21 | 22 | const childNotSupported = () => { 23 | throw new Error('Element does not support children.'); 24 | }; 25 | 26 | const noStyle = {}; 27 | 28 | const yogaConfig = Yoga.Config.create(); 29 | 30 | yogaConfig.setPointScaleFactor(0); 31 | 32 | export default class BaseElement { 33 | 34 | onDragStart = () => { 35 | this.isClickValid = false; 36 | }; 37 | 38 | constructor (props, root) { 39 | this._layoutDirty = true; 40 | this.root = root; 41 | this.layoutNode = Yoga.Node.create(yogaConfig); 42 | this.onLayoutCallback = null; 43 | this.bounds = new Rectangle(); 44 | this.cachedLayout = { 45 | left: 0, 46 | top: 0, 47 | width: 0, 48 | height: 0 49 | }; 50 | this.style = noStyle; 51 | this.anchorX = 0.5; 52 | this.anchorY = 0.5; 53 | this.offsetX = 0; 54 | this.offsetY = 0; 55 | this.scaleX = 1; 56 | this.scaleY = 1; 57 | this.skewX = 0; 58 | this.skewY = 0; 59 | 60 | this.displayObject = this.createDisplayObject(props); 61 | this.displayObject.on('pointertap', this.onClick, this); 62 | 63 | this._isMeasureFunctionSet = false; 64 | this.updateMeasureFunction(false); 65 | 66 | this.isClickValid = false; 67 | this._clickHandler = null; 68 | 69 | } 70 | 71 | removeAllChildrenRecursive () { 72 | // Noop for container clear 73 | } 74 | 75 | hasChild (child) { 76 | childNotSupported(); 77 | } 78 | 79 | addChild (child) { 80 | childNotSupported(); 81 | } 82 | 83 | addChildAt (child, index) { 84 | childNotSupported(); 85 | } 86 | 87 | removeChild (child) { 88 | childNotSupported(); 89 | } 90 | 91 | setChildIndex(child, index) { 92 | childNotSupported(); 93 | } 94 | 95 | getChildIndex (child) { 96 | childNotSupported(); 97 | } 98 | 99 | applyInteractiveListeners (oldProps, newProps) { 100 | let { isInteractive } = newProps; 101 | let isAutoInteractive = false; 102 | 103 | for (let i = 0; i < interactivePropCount; i++) { 104 | const key = interactivePropList[i]; 105 | const propName = interactiveProps[key]; 106 | const oldValue = oldProps[propName]; 107 | const newValue = newProps[propName]; 108 | 109 | isAutoInteractive = isAutoInteractive || !!newValue; 110 | 111 | if (propName === 'onClick') { 112 | this.clickHandler = newValue; 113 | } else if (oldValue !== newValue) { 114 | oldValue && this.displayObject.removeListener(key, oldValue); 115 | newValue && this.displayObject.on(key, newValue); 116 | } 117 | } 118 | 119 | // Cancel clicks for drag events 120 | 121 | isInteractive = isInteractive === undefined ? isAutoInteractive : isInteractive; 122 | 123 | this.displayObject.interactive = isInteractive; 124 | // this.displayObject.hitArea = this.bounds; 125 | } 126 | 127 | applyProps (oldProps, newProps) { 128 | this.applyInteractiveListeners(oldProps, newProps); 129 | 130 | const { interactiveChildren = true, mask = null } = newProps; 131 | const { mask: oldMask = null } = oldProps; 132 | 133 | 134 | this.displayObject.interactiveChildren = interactiveChildren; 135 | 136 | this.onLayoutCallback = newProps.onLayout || null; 137 | 138 | const newStyle = newProps.style ? mergeStyles(newProps.style) : noStyle; 139 | let layoutDirty = applyLayoutProperties(this.layoutNode, this.style, newStyle); 140 | 141 | this.style = newStyle; 142 | this.displayObject.alpha = this.parsePercentage(this.style.alpha, 1); 143 | 144 | const { props, list, count } = this.constructor.defaultProps; 145 | 146 | for (let i = 0; i < count; i++) { 147 | const key = list[i]; 148 | const newValue = this.style[key]; 149 | const newValueIsUndefined = newValue === undefined; 150 | 151 | if (newValueIsUndefined || this.style[key] !== undefined) { 152 | this.displayObject[key] = newValueIsUndefined ? props[key] : newValue; 153 | } 154 | } 155 | 156 | if (oldMask !== mask) { 157 | this.displayObject.mask = mask; 158 | } 159 | 160 | const anchorX = this.parsePercentage(this.style.anchorX, 0.5); 161 | const anchorY = this.parsePercentage(this.style.anchorY, 0.5); 162 | const scaleX = this.parsePercentage(this.style.scaleX, 1); 163 | const scaleY = this.parsePercentage(this.style.scaleY, 1); 164 | 165 | const offsetX = this.style.offsetX || 0; 166 | const offsetY = this.style.offsetY || 0; 167 | const skewX = this.style.skewX || 0; 168 | const skewY = this.style.skewY || 0; 169 | 170 | const anchorsDirty = anchorX !== this.anchorX || anchorY !== this.anchorY || scaleX !== this.scaleX || scaleY !== this.scaleY; 171 | const transformDirty = offsetX !== this.offsetX || offsetY !== this.offsetY || skewX !== this.skewX || skewY !== this.skewY; 172 | 173 | if (anchorsDirty) { 174 | this.anchorX = anchorX; 175 | this.anchorY = anchorY; 176 | this.displayObject.scale.x = this.scaleX = scaleX; 177 | this.displayObject.scale.y = this.scaleY = scaleY; 178 | } 179 | 180 | if (transformDirty) { 181 | this.offsetX = offsetX; 182 | this.offsetY = offsetY; 183 | this.skewX = skewX; 184 | this.skewY = skewY; 185 | } 186 | 187 | if (layoutDirty || anchorsDirty) { 188 | this.layoutDirty = true; 189 | } else if (transformDirty) { 190 | this.applyTransform(); 191 | } 192 | } 193 | 194 | applyLayout () { 195 | const newLayout = this.layoutNode.getComputedLayout(); 196 | const cached = this.cachedLayout; 197 | 198 | const boundsDirty = newLayout.left !== cached.x || newLayout.top !== cached.y || 199 | newLayout.width !== cached.width || newLayout.height !== cached.height; 200 | 201 | if (this.layoutDirty || boundsDirty) { 202 | cached.x = newLayout.left; 203 | cached.y = newLayout.top; 204 | cached.width = newLayout.width; 205 | cached.height = newLayout.height; 206 | 207 | this.bounds.width = cached.width / this.displayObject.scale.x; 208 | this.bounds.height = cached.height / this.displayObject.scale.y; 209 | 210 | this.applyTransform(); 211 | this.onLayout(cached.x, cached.y, cached.width, cached.height); 212 | 213 | if (boundsDirty && this.onLayoutCallback) { 214 | this.root.addToCallbackPool(this); 215 | } 216 | 217 | this.layoutDirty = false; 218 | } 219 | } 220 | 221 | applyTransform () { 222 | const cached = this.cachedLayout; 223 | 224 | const anchorOffsetX = this.anchorX * cached.width; 225 | const anchorOffsetY = this.anchorY * cached.height; 226 | 227 | this.displayObject.skew.set(this.skewX, this.skewY); 228 | 229 | this.displayObject.position.set( 230 | cached.x + anchorOffsetX + this.offsetX, 231 | cached.y + anchorOffsetY + this.offsetY 232 | ); 233 | } 234 | 235 | onLayout (x, y, width, height) { 236 | } 237 | 238 | parsePercentage (value, defaultValue) { 239 | if (value === undefined) { 240 | return defaultValue; 241 | } 242 | 243 | if (typeof value === 'string') { 244 | return value.endsWith('%') ? Number(value.substring(1, 0)) * 0.01 : Number(value); 245 | } 246 | 247 | return value; 248 | } 249 | 250 | updateMeasureFunction (hasChildren) { 251 | if (this._isMeasureFunctionSet && hasChildren) { 252 | this._isMeasureFunctionSet = false; 253 | this.layoutNode.setMeasureFunc(null); 254 | } else if (!this._isMeasureFunctionSet && !hasChildren && this.measure) { 255 | this._isMeasureFunctionSet = true; 256 | this.layoutNode.setMeasureFunc( 257 | (node, width, widthMode, height, heightMode) => 258 | this.measure(node, width, widthMode, height, heightMode) 259 | ); 260 | } 261 | } 262 | 263 | destroy () { 264 | if (this.onLayoutCallback) { 265 | this.root.removeFromCallbackPool(this); 266 | } 267 | 268 | this.clickHandler = null; 269 | this.displayObject.destroy(); 270 | this.displayObject = null; 271 | this.layoutNode.free(); 272 | this.layoutNode = null; 273 | } 274 | 275 | createDisplayObject () { 276 | throw new Error('Cannot instantiate base class'); 277 | } 278 | 279 | onDown (event) { 280 | this.isClickValid = true; 281 | } 282 | 283 | onClick (event) { 284 | this.isClickValid && this.clickHandler && this.clickHandler(event); 285 | } 286 | 287 | get clickHandler () { 288 | return this._clickHandler; 289 | } 290 | 291 | set clickHandler (value) { 292 | if (this._clickHandler === value) { 293 | return; 294 | } 295 | 296 | this._clickHandler = value; 297 | 298 | if (value) { 299 | this.displayObject.on('pointerdown', this.onDown, this); 300 | window.addEventListener('dragStart', this.onDragStart, false); 301 | } else { 302 | this.displayObject.removeListener('pointerdown', this.onDown, this); 303 | window.removeEventListener('dragStart', this.onDragStart, false); 304 | } 305 | } 306 | 307 | get layoutDirty () { 308 | return this._layoutDirty; 309 | } 310 | 311 | set layoutDirty (value) { 312 | this._layoutDirty = value; 313 | 314 | if (!value) { 315 | return; 316 | } 317 | 318 | if (this.root && this.root !== this) { 319 | this.root.layoutDirty = true; 320 | } 321 | } 322 | 323 | get publicInstance () { 324 | return this.displayObject; 325 | } 326 | 327 | static get defaultProps () { 328 | if (!this._defaultProps) { 329 | const props = this.listDefaultStyleProps(); 330 | const list = Object.keys(props); 331 | const count = list.length; 332 | 333 | this._defaultProps = { props, list, count }; 334 | this._defaultProps.count = this._defaultProps.list.length; 335 | } 336 | 337 | return this._defaultProps; 338 | } 339 | 340 | static listDefaultStyleProps () { 341 | return { 342 | blendMode: BLEND_MODES.NORMAL, 343 | buttonMode: false, 344 | cacheAsBitmap: false, 345 | cursor: 'auto', 346 | filterArea: null, 347 | filters: null, 348 | renderable: true, 349 | rotation: 0, 350 | visible: true, 351 | tint: 0xffffff, 352 | sortableChildren: false, 353 | zIndex: 0 354 | }; 355 | } 356 | 357 | } 358 | -------------------------------------------------------------------------------- /src/elements/ScrollContainer.js: -------------------------------------------------------------------------------- 1 | import { Container, Rectangle, Point, Sprite, Texture, Ticker } from 'pixi.js'; 2 | import ContainerElement from './Container'; 3 | 4 | const { abs, log, max, pow } = Math; 5 | 6 | const VELOCITY_MIN = 0.1; 7 | const DAMPING = 0.01; 8 | const LOG_DAMPING = log(DAMPING); 9 | 10 | const ticker = Ticker.shared; 11 | 12 | const SCRATCH_POINT = new Point(); 13 | 14 | class ScrollContentContainer extends Container { 15 | 16 | static BOUNDS_UPDATED = 'BOUNDS_UPDATED'; 17 | 18 | _localBoundsCache = new Rectangle(); 19 | _isUpdatingBounds = false; 20 | 21 | updateTransform () { 22 | if (!this._isUpdatingBounds) { 23 | this._isUpdatingBounds = true; 24 | 25 | const { x: oX, y: oY, width: oWidth, height: oHeight } = this._localBoundsCache; 26 | 27 | this.getLocalBounds(this._localBoundsCache, true); 28 | 29 | if (this._localBoundsCache.x < 0) { 30 | this._localBoundsCache.width += this._localBoundsCache.x; 31 | this._localBoundsCache.x = 0; 32 | } 33 | 34 | if (this._localBoundsCache.y < 0) { 35 | this._localBoundsCache.height += this._localBoundsCache.y; 36 | this._localBoundsCache.y = 0; 37 | } 38 | 39 | const { x, y, width, height } = this._localBoundsCache; 40 | 41 | if (oX !== x || oY !== y || oWidth !== width || oHeight !== height) { 42 | this.emit(ScrollContentContainer.BOUNDS_UPDATED, this._localBoundsCache); 43 | } 44 | 45 | this._isUpdatingBounds = false; 46 | } 47 | 48 | super.updateTransform(); 49 | } 50 | 51 | get left () { 52 | return this._localBoundsCache.x; 53 | } 54 | 55 | get top () { 56 | return this._localBoundsCache.y; 57 | } 58 | 59 | get height () { 60 | return this._localBoundsCache.height; 61 | } 62 | 63 | get width () { 64 | return this._localBoundsCache.width; 65 | } 66 | 67 | } 68 | 69 | class ScrollContainer extends Container { 70 | 71 | static DRAG_START = 'dragStart'; 72 | static DRAG = 'drag'; 73 | static DRAG_STOP = 'dragStop'; 74 | 75 | onWheel = ({ deltaX, deltaY }) => { 76 | if (this.isScrollWheelEnabled) { 77 | this.scrollBy(deltaX, deltaY); 78 | } 79 | }; 80 | 81 | constructor () { 82 | super(); 83 | 84 | this.interactionManager = null; 85 | 86 | this._isDragging = false; 87 | this._inputRoot = null; 88 | this._initialPointerPosition = new Point(); 89 | this._lastPointerPosition = new Point(); 90 | this._lastScrollTime = 0; 91 | this._lastScrollDuration = 0; 92 | this._lastScrollDelta = new Point(); 93 | this._velocity = new Point(); 94 | this._isTicking = false; 95 | 96 | this.content = this.addChild(new ScrollContentContainer()); 97 | this.on('pointerdown', this.onPointerDown, this); 98 | this.on('pointerover', this.onPointerOver, this); 99 | this.on('pointerout', this.onPointerOut, this); 100 | this.content.on(ScrollContentContainer.BOUNDS_UPDATED, this.onContentBoundsUpdated, this); 101 | 102 | this.contentMask = this.addChild(new Sprite(Texture.WHITE)); 103 | this.content.mask = this.contentMask; 104 | 105 | this.width = 100; 106 | this.height = 100; 107 | this.isScrollWheelEnabled = true; 108 | this.dragThreshold = 10; 109 | } 110 | 111 | destroy () { 112 | this.isTicking = false; 113 | this.removeListener('pointerdown', this.onPointerDown, this); 114 | this.interactionManager.removeListener('pointermove', this.onPointerMove, this); 115 | this.interactionManager.removeListener('pointerup', this.onPointerUp, this); 116 | this.interactionManager.removeListener('pointerout', this.onPointerUp, this); 117 | this.interactionManager.removeListener('pointercancel', this.onPointerUp, this); 118 | super.destroy(); 119 | } 120 | 121 | scrollBy (x, y) { 122 | const previousLeft = this.scrollLeft; 123 | const previousTop = this.scrollTop; 124 | 125 | this.scrollLeft += x; 126 | this.scrollTop += y; 127 | 128 | if (this.scrollTop !== previousTop || this.scrollLeft !== previousLeft) { 129 | this.emit('scrolled'); 130 | } 131 | } 132 | 133 | setVelocity (vx = 0, vy = 0) { 134 | if (vx < VELOCITY_MIN && vx > -VELOCITY_MIN) { 135 | vx = 0; 136 | } 137 | 138 | if (vy < VELOCITY_MIN && vy > -VELOCITY_MIN) { 139 | vy = 0; 140 | } 141 | 142 | this._velocity.set(vx, vy); 143 | this.isTicking = Boolean(vx || vy); 144 | } 145 | 146 | onContentBoundsUpdated () { 147 | // Reset scroll position in case bounds shifted 148 | this.scrollLeft = this.scrollLeft; // eslint-disable-line 149 | this.scrollTop = this.scrollTop; // eslint-disable-line 150 | } 151 | 152 | onPointerDown (event) { 153 | this._lastScrollTime = Date.now(); 154 | this.removeListener('pointerdown', this.onPointerDown, this); 155 | this.toLocal(event.data.global, null, this._initialPointerPosition); 156 | this._lastPointerPosition.copyFrom(this._initialPointerPosition); 157 | this.interactionManager.on('pointermove', this.onPointerMove, this); 158 | this.interactionManager.on('pointerup', this.onPointerUp, this); 159 | this.interactionManager.on('pointerout', this.onPointerUp, this); 160 | this.interactionManager.on('pointercancel', this.onPointerUp, this); 161 | } 162 | 163 | onPointerMove (event) { 164 | const now = Date.now(); 165 | const newPosition = this.toLocal(event.data.global, null, SCRATCH_POINT); 166 | const dx = this._isScrollXEnabled ? this._lastPointerPosition.x - newPosition.x : 0; 167 | const dy = this._isScrollYEnabled ? this._lastPointerPosition.y - newPosition.y : 0; 168 | this._lastPointerPosition.copyFrom(newPosition); 169 | this._lastScrollDelta.set(dx, dy); 170 | this._lastScrollDuration = now - this._lastScrollTime; 171 | this._lastScrollTime = now; 172 | 173 | if (this._isDragging) { 174 | this.scrollBy(dx, dy); 175 | this.emit(ScrollContainer.DRAG, event); 176 | return; 177 | } 178 | 179 | const didDrag = (this._isScrollXEnabled && abs(this._initialPointerPosition.x - newPosition.x) > this.dragThreshold) 180 | || (this.isScrollYEnabled && abs(this._initialPointerPosition.y - newPosition.y) > this.dragThreshold); 181 | 182 | if (didDrag) { 183 | this._isDragging = true; 184 | this.emit(ScrollContainer.DRAG_START, event); 185 | } 186 | } 187 | 188 | onPointerOut (event) { 189 | document.removeEventListener('wheel', this.onWheel); 190 | } 191 | 192 | onPointerOver (event) { 193 | document.addEventListener('wheel', this.onWheel, { passive: true }); 194 | } 195 | 196 | onPointerUp (event) { 197 | this.interactionManager.removeListener('pointermove', this.onPointerMove, this); 198 | this.interactionManager.removeListener('pointerup', this.onPointerUp, this); 199 | this.interactionManager.removeListener('pointerout', this.onPointerUp, this); 200 | this.interactionManager.removeListener('pointercancel', this.onPointerUp, this); 201 | this.on('pointerdown', this.onPointerDown, this); 202 | 203 | if (!this._isDragging) { 204 | return; 205 | } 206 | 207 | const multiplier = this._lastScrollDuration ? 1 / (this._lastScrollDuration * 0.001): 0; 208 | this.setVelocity(this._lastScrollDelta.x * multiplier, this._lastScrollDelta.y * multiplier); 209 | this.emit(ScrollContainer.DRAG_STOP, event); 210 | this._isDragging = false; 211 | } 212 | 213 | onTick () { 214 | const dt = ticker.deltaMS * 0.001; 215 | const powDamping = pow(DAMPING, dt); 216 | const dampingMultiplier = (powDamping - 1) / LOG_DAMPING; 217 | 218 | let { x: vx, y: vy } = this._velocity; 219 | 220 | this.scrollBy(vx * dampingMultiplier, vy * dampingMultiplier); 221 | this.setVelocity(vx * powDamping, vy * powDamping); 222 | } 223 | 224 | get isScrollXEnabled () { 225 | return this._isScrollXEnabled; 226 | } 227 | 228 | set isScrollXEnabled (value) { 229 | if (!value) { 230 | this._velocity.x = 0; 231 | } 232 | 233 | this._isScrollXEnabled = value; 234 | } 235 | 236 | get isScrollYEnabled () { 237 | return this._isScrollYEnabled; 238 | } 239 | 240 | set isScrollYEnabled (value) { 241 | if (!value) { 242 | this._velocity.y = 0; 243 | } 244 | 245 | this._isScrollYEnabled = value; 246 | } 247 | 248 | get scrollLeft () { 249 | return this.content.left - this.content.position.x; 250 | } 251 | 252 | set scrollLeft (value) { 253 | const maxScroll = this.content.width - this.width; 254 | value = value < 0 ? 0 : (value > maxScroll ? maxScroll : value); 255 | this.content.position.x = -(value + this.content.left); 256 | } 257 | 258 | get scrollTop () { 259 | return this.content.top - this.content.position.y; 260 | } 261 | 262 | set scrollTop (value) { 263 | const maxScroll = max(0, this.content.height - this.height); 264 | value = value < 0 ? 0 : (value > maxScroll ? maxScroll : value); 265 | this.content.position.y = -(value + this.content.top); 266 | } 267 | 268 | get scrollHeight () { 269 | return this.content.height; 270 | } 271 | 272 | get scrollWidth () { 273 | return this.content.width; 274 | } 275 | 276 | get height () { 277 | return this._height; 278 | } 279 | 280 | set height (value) { 281 | this._height = value; 282 | this.contentMask.height = value; 283 | } 284 | 285 | get width () { 286 | return this._width; 287 | } 288 | 289 | set width (value) { 290 | this._width = value; 291 | this.contentMask.width = value; 292 | } 293 | 294 | get clientWidth () { 295 | return this.width; 296 | } 297 | 298 | get clientHeight () { 299 | return this.height; 300 | } 301 | 302 | get isTicking () { 303 | return this._isTicking; 304 | } 305 | 306 | set isTicking (value) { 307 | if (this._isTicking === value) { 308 | return; 309 | } 310 | 311 | this._isTicking = value; 312 | 313 | value 314 | ? ticker.add(this.onTick, this) 315 | : ticker.remove(this.onTick, this); 316 | } 317 | 318 | } 319 | 320 | export default class ScrollContainerElement extends ContainerElement { 321 | 322 | constructor (props, root) { 323 | super(props, root); 324 | this.displayObject.interactionManager = root.application.renderer.plugins.interaction; 325 | this.scrollHandler = null; 326 | this.dragStartHandler = null; 327 | this.dragHandler = null; 328 | this.dragStopHandler = null; 329 | this.displayObject.on('scrolled', this.onScroll, this); 330 | this.displayObject.on(ScrollContainer.DRAG_START, this.onDragStart, this); 331 | this.displayObject.on(ScrollContainer.DRAG, this.onDrag, this); 332 | this.displayObject.on(ScrollContainer.DRAG_STOP, this.onDragStop, this); 333 | this.interactiveChildren = true; 334 | } 335 | 336 | destroy () { 337 | this.displayObject.removeListener(ScrollContainer.DRAG_START, this.onDragStart, this); 338 | this.displayObject.removeListener(ScrollContainer.DRAG, this.onDrag, this); 339 | this.displayObject.removeListener(ScrollContainer.DRAG_STOP, this.onDragStop, this); 340 | this.displayObject.removeListener('scrolled', this.onScroll, this); 341 | super.destroy(); 342 | } 343 | 344 | applyInteractiveListeners (oldProps, newProps) { 345 | super.applyInteractiveListeners(oldProps, newProps); 346 | this.displayObject.interactive = true; 347 | this.displayObject.hitArea = this.bounds; 348 | } 349 | 350 | applyProps (oldProps, newProps) { 351 | super.applyProps(oldProps, newProps); 352 | 353 | const { 354 | dragThreshold = 10, 355 | isScrollXEnabled = true, 356 | isScrollYEnabled = true, 357 | isScrollWheelEnabled = true, 358 | onScroll, 359 | onDragStart, 360 | onDrag, 361 | onDragStop, 362 | interactiveChildren = true 363 | } = newProps; 364 | 365 | this.interactiveChildren = interactiveChildren; 366 | this.scrollHandler = onScroll; 367 | this.dragStartHandler = onDragStart; 368 | this.dragHandler = onDrag; 369 | this.dragStopHandler = onDragStop; 370 | this.displayObject.isScrollWheelEnabled = isScrollWheelEnabled; 371 | this.displayObject.isScrollXEnabled = isScrollXEnabled; 372 | this.displayObject.isScrollYEnabled = isScrollYEnabled; 373 | this.displayObject.dragThreshold = dragThreshold; 374 | } 375 | 376 | createDisplayObject () { 377 | return new ScrollContainer(); 378 | } 379 | 380 | getChildContainer () { 381 | return this.displayObject.content; 382 | } 383 | 384 | onLayout (x, y, width, height) { 385 | super.onLayout(x, y, width, height); 386 | this.displayObject.height = height; 387 | this.displayObject.width = width; 388 | } 389 | 390 | onScroll () { 391 | this.scrollHandler && this.scrollHandler({ currentTarget: this.displayObject }); 392 | } 393 | 394 | onDragStart (event) { 395 | this.dragStartHandler && this.dragStartHandler(event); 396 | this.displayObject.interactiveChildren = false; 397 | } 398 | 399 | onDrag (event) { 400 | this.dragHandler && this.dragHandler(event); 401 | } 402 | 403 | onDragStop (event) { 404 | this.displayObject.interactiveChildren = this.interactiveChildren; 405 | this.dragStopHandler && this.dragStopHandler(event); 406 | } 407 | 408 | get isClippingEnabled () { 409 | return true; 410 | } 411 | 412 | set isClippingEnabled (isClippingEnabled) { 413 | // noop 414 | } 415 | 416 | } 417 | -------------------------------------------------------------------------------- /src/applyLayoutProperties.js: -------------------------------------------------------------------------------- 1 | import * as Yoga from 'typeflex'; 2 | 3 | /** 4 | * applyLayoutProperties.js 5 | * Copyright 2017 Raymond Cook 6 | * 7 | * Derived from yoga-js -- https://github.com/vincentriemer/yoga-js 8 | * Copyright 2017 Vincent Riemer 9 | * 10 | * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), 11 | * to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, 12 | * 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: 13 | * 14 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * 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 19 | * DEALINGS IN THE SOFTWARE. 20 | **/ 21 | 22 | export const defaultValues = { 23 | left: NaN, 24 | right: NaN, 25 | top: NaN, 26 | bottom: NaN, 27 | alignContent: 'flex-start', 28 | alignItems: 'stretch', 29 | alignSelf: 'auto', 30 | flexDirection: 'row', 31 | flexWrap: 'no-wrap', 32 | justifyContent: 'flex-start', 33 | margin: NaN, 34 | marginBottom: NaN, 35 | marginHorizontal: NaN, 36 | marginLeft: NaN, 37 | marginRight: NaN, 38 | marginTop: NaN, 39 | marginVertical: NaN, 40 | overflow: 'visible', 41 | display: 'flex', 42 | flex: 0, 43 | flexBasis: 'auto', 44 | flexGrow: 0, 45 | flexShrink: 1, 46 | aspectRatio: NaN, 47 | width: 'auto', 48 | height: 'auto', 49 | minWidth: NaN, 50 | minHeight: NaN, 51 | maxWidth: NaN, 52 | maxHeight: NaN, 53 | borderWidth: NaN, 54 | borderWidthBottom: NaN, 55 | borderWidthHorizontal: NaN, 56 | borderWidthLeft: NaN, 57 | borderWidthRight: NaN, 58 | borderWidthTop: NaN, 59 | borderWidthVertical: NaN, 60 | padding: NaN, 61 | paddingBottom: NaN, 62 | paddingHorizontal: NaN, 63 | paddingLeft: NaN, 64 | paddingRight: NaN, 65 | paddingTop: NaN, 66 | paddingVertical: NaN, 67 | position: 'relative' 68 | }; 69 | 70 | export const alignEnumMapping = { 71 | auto: Yoga.ALIGN_AUTO, 72 | 'flex-start': Yoga.ALIGN_FLEX_START, 73 | center: Yoga.ALIGN_CENTER, 74 | 'flex-end': Yoga.ALIGN_FLEX_END, 75 | stretch: Yoga.ALIGN_STRETCH, 76 | baseline: Yoga.ALIGN_BASELINE, 77 | 'space-between': Yoga.ALIGN_SPACE_BETWEEN, 78 | 'space-around': Yoga.ALIGN_SPACE_AROUND 79 | }; 80 | 81 | export const flexDirectionEnumMapping = { 82 | column: Yoga.FLEX_DIRECTION_COLUMN, 83 | 'column-reverse': Yoga.FLEX_DIRECTION_COLUMN_REVERSE, 84 | row: Yoga.FLEX_DIRECTION_ROW, 85 | 'row-reverse': Yoga.FLEX_DIRECTION_ROW_REVERSE 86 | }; 87 | 88 | export const flexWrapEnumMapping = { 89 | 'no-wrap': Yoga.WRAP_NO_WRAP, 90 | wrap: Yoga.WRAP_WRAP, 91 | 'wrap-reverse': Yoga.WRAP_WRAP_REVERSE 92 | }; 93 | 94 | export const justifyContentEnumMapping = { 95 | 'flex-start': Yoga.JUSTIFY_FLEX_START, 96 | center: Yoga.JUSTIFY_CENTER, 97 | 'flex-end': Yoga.JUSTIFY_FLEX_END, 98 | 'space-between': Yoga.JUSTIFY_SPACE_BETWEEN, 99 | 'space-around': Yoga.JUSTIFY_SPACE_AROUND, 100 | 'space-evenly': Yoga.JUSTIFY_SPACE_EVENLY 101 | }; 102 | 103 | export const overflowEnumMapping = { 104 | visible: Yoga.OVERFLOW_VISIBLE, 105 | hidden: Yoga.OVERFLOW_HIDDEN, 106 | scroll: Yoga.OVERFLOW_SCROLL 107 | }; 108 | 109 | export const displayEnumMapping = { 110 | flex: Yoga.DISPLAY_FLEX, 111 | none: Yoga.DISPLAY_NONE 112 | }; 113 | 114 | export const positionTypeEnumMapping = { 115 | relative: Yoga.POSITION_TYPE_RELATIVE, 116 | absolute: Yoga.POSITION_TYPE_ABSOLUTE 117 | }; 118 | 119 | const setterMap = { 120 | 121 | alignContent: function (node, value) { 122 | node.setAlignContent(alignEnumMapping[value]); 123 | }, 124 | 125 | alignItems: function (node, value) { 126 | node.setAlignItems(alignEnumMapping[value]); 127 | }, 128 | 129 | alignSelf: function (node, value) { 130 | node.setAlignSelf(alignEnumMapping[value]); 131 | }, 132 | 133 | flexDirection: function (node, value) { 134 | node.setFlexDirection(flexDirectionEnumMapping[value]); 135 | }, 136 | 137 | flexWrap: function (node, value) { 138 | node.setFlexWrap(flexWrapEnumMapping[value]); 139 | }, 140 | 141 | justifyContent: function (node, value) { 142 | node.setJustifyContent(justifyContentEnumMapping[value]); 143 | }, 144 | 145 | left: function (node, value) { 146 | node.setPosition(Yoga.EDGE_LEFT, value); 147 | }, 148 | 149 | right: function (node, value) { 150 | node.setPosition(Yoga.EDGE_RIGHT, value); 151 | }, 152 | 153 | top: function (node, value) { 154 | node.setPosition(Yoga.EDGE_TOP, value); 155 | }, 156 | 157 | bottom: function (node, value) { 158 | node.setPosition(Yoga.EDGE_BOTTOM, value); 159 | }, 160 | 161 | margin: function (node, value) { 162 | if (typeof value === 'string') { 163 | const valueList = value.split(' '); 164 | 165 | switch (valueList.length) { 166 | 167 | case 1: 168 | node.setMargin(Yoga.EDGE_ALL, valueList[0]); 169 | break; 170 | 171 | case 2: 172 | node.setMargin(Yoga.EDGE_VERTICAL, valueList[0]); 173 | node.setMargin(Yoga.EDGE_HORIZONTAL, valueList[1]); 174 | break; 175 | 176 | case 3: 177 | node.setMargin(Yoga.EDGE_TOP, valueList[0]); 178 | node.setMargin(Yoga.EDGE_HORIZONTAL, valueList[1]); 179 | node.setMargin(Yoga.EDGE_BOTTOM, valueList[2]); 180 | break; 181 | 182 | case 4: 183 | node.setMargin(Yoga.EDGE_TOP, valueList[0]); 184 | node.setMargin(Yoga.EDGE_RIGHT, valueList[1]); 185 | node.setMargin(Yoga.EDGE_BOTTOM, valueList[2]); 186 | node.setMargin(Yoga.EDGE_LEFT, valueList[3]); 187 | break; 188 | 189 | default: 190 | console.warn('Bad value passed to "margin"', value); 191 | break; 192 | 193 | } 194 | } else if (typeof value === 'number') { 195 | node.setMargin(Yoga.EDGE_ALL, value); 196 | } 197 | }, 198 | 199 | marginBottom: function (node, value) { 200 | node.setMargin(Yoga.EDGE_BOTTOM, value); 201 | }, 202 | 203 | marginHorizontal: function (node, value) { 204 | node.setMargin(Yoga.EDGE_HORIZONTAL, value); 205 | }, 206 | 207 | marginLeft: function (node, value) { 208 | node.setMargin(Yoga.EDGE_LEFT, value); 209 | }, 210 | 211 | marginRight: function (node, value) { 212 | node.setMargin(Yoga.EDGE_RIGHT, value); 213 | }, 214 | 215 | marginTop: function (node, value) { 216 | node.setMargin(Yoga.EDGE_TOP, value); 217 | }, 218 | 219 | marginVertical: function (node, value) { 220 | node.setMargin(Yoga.EDGE_VERTICAL, value); 221 | }, 222 | 223 | 224 | overflow: function (node, value) { 225 | node.setOverflow(overflowEnumMapping[value]); 226 | }, 227 | 228 | display: function (node, value) { 229 | node.setDisplay(displayEnumMapping[value]); 230 | }, 231 | 232 | flex: function (node, value) { 233 | node.setFlex(value); 234 | }, 235 | 236 | flexBasis: function (node, value) { 237 | node.setFlexBasis(value); 238 | }, 239 | 240 | flexGrow: function (node, value) { 241 | node.setFlexGrow(value); 242 | }, 243 | 244 | flexShrink: function (node, value) { 245 | node.setFlexShrink(value); 246 | }, 247 | 248 | aspectRatio: function (node, value) { 249 | node.setAspectRatio(value); 250 | }, 251 | 252 | width: function (node, value) { 253 | node.setWidth(value); 254 | }, 255 | 256 | height: function (node, value) { 257 | node.setHeight(value); 258 | }, 259 | 260 | minWidth: function (node, value) { 261 | node.setMinWidth(value); 262 | }, 263 | 264 | minHeight: function (node, value) { 265 | node.setMinHeight(value); 266 | }, 267 | 268 | maxWidth: function (node, value) { 269 | node.setMaxWidth(value); 270 | }, 271 | 272 | maxHeight: function (node, value) { 273 | node.setMaxHeight(value); 274 | }, 275 | 276 | borderWidth: function (node, value) { 277 | if (typeof value === 'string') { 278 | const valueList = value.split(' '); 279 | 280 | switch (valueList.length) { 281 | 282 | case 1: 283 | node.setBorder(Yoga.EDGE_ALL, valueList[0]); 284 | break; 285 | 286 | case 2: 287 | node.setBorder(Yoga.EDGE_VERTICAL, valueList[0]); 288 | node.setBorder(Yoga.EDGE_HORIZONTAL, valueList[1]); 289 | break; 290 | 291 | case 3: 292 | node.setBorder(Yoga.EDGE_TOP, valueList[0]); 293 | node.setBorder(Yoga.EDGE_HORIZONTAL, valueList[1]); 294 | node.setBorder(Yoga.EDGE_BOTTOM, valueList[2]); 295 | break; 296 | 297 | case 4: 298 | node.setBorder(Yoga.EDGE_TOP, valueList[0]); 299 | node.setBorder(Yoga.EDGE_RIGHT, valueList[1]); 300 | node.setBorder(Yoga.EDGE_BOTTOM, valueList[2]); 301 | node.setBorder(Yoga.EDGE_LEFT, valueList[3]); 302 | break; 303 | 304 | default: 305 | console.warn('Bad value passed to "borderWidth"', value); 306 | break; 307 | 308 | } 309 | } else if (typeof value === 'number') { 310 | node.setBorder(Yoga.EDGE_ALL, value); 311 | } 312 | }, 313 | 314 | borderBottomWidth: function (node, value) { 315 | node.setBorder(Yoga.EDGE_BOTTOM, value); 316 | }, 317 | 318 | borderLeftWidth: function (node, value) { 319 | node.setBorder(Yoga.EDGE_LEFT, value); 320 | }, 321 | 322 | borderRightWidth: function (node, value) { 323 | node.setBorder(Yoga.EDGE_RIGHT, value); 324 | }, 325 | 326 | borderTopWidth: function (node, value) { 327 | node.setBorder(Yoga.EDGE_TOP, value); 328 | }, 329 | 330 | padding: function (node, value) { 331 | if (typeof value === 'string') { 332 | const valueList = value.split(' '); 333 | 334 | switch (valueList.length) { 335 | 336 | case 1: 337 | node.setPadding(Yoga.EDGE_ALL, valueList[0]); 338 | break; 339 | 340 | case 2: 341 | node.setPadding(Yoga.EDGE_VERTICAL, valueList[0]); 342 | node.setPadding(Yoga.EDGE_HORIZONTAL, valueList[1]); 343 | break; 344 | 345 | case 3: 346 | node.setPadding(Yoga.EDGE_TOP, valueList[0]); 347 | node.setPadding(Yoga.EDGE_HORIZONTAL, valueList[1]); 348 | node.setPadding(Yoga.EDGE_BOTTOM, valueList[2]); 349 | break; 350 | 351 | case 4: 352 | node.setPadding(Yoga.EDGE_TOP, valueList[0]); 353 | node.setPadding(Yoga.EDGE_RIGHT, valueList[1]); 354 | node.setPadding(Yoga.EDGE_BOTTOM, valueList[2]); 355 | node.setPadding(Yoga.EDGE_LEFT, valueList[3]); 356 | break; 357 | 358 | default: 359 | console.warn('Bad value passed to "padding"', value); 360 | break; 361 | 362 | } 363 | } else if (typeof value === 'number') { 364 | node.setPadding(Yoga.EDGE_ALL, value); 365 | } 366 | }, 367 | 368 | paddingBottom: function (node, value) { 369 | node.setPadding(Yoga.EDGE_BOTTOM, value); 370 | }, 371 | 372 | paddingHorizontal: function (node, value) { 373 | node.setPadding(Yoga.EDGE_HORIZONTAL, value); 374 | }, 375 | 376 | paddingLeft: function (node, value) { 377 | node.setPadding(Yoga.EDGE_LEFT, value); 378 | }, 379 | 380 | paddingRight: function (node, value) { 381 | node.setPadding(Yoga.EDGE_RIGHT, value); 382 | }, 383 | 384 | paddingTop: function (node, value) { 385 | node.setPadding(Yoga.EDGE_TOP, value); 386 | }, 387 | 388 | paddingVertical: function (node, value) { 389 | node.setPadding(Yoga.EDGE_VERTICAL, value); 390 | }, 391 | 392 | position: function (node, value) { 393 | node.setPositionType(positionTypeEnumMapping[value]); 394 | } 395 | 396 | }; 397 | 398 | function isShallowEqual (props1, props2) { 399 | for (const key in props1) { 400 | if (setterMap[key] && (!props2.hasOwnProperty(key) || props1[key] !== props2[key])) { 401 | return false; 402 | } 403 | } 404 | 405 | for (const key in props2) { 406 | if (setterMap[key] && (!props1.hasOwnProperty(key) || props1[key] !== props2[key])) { 407 | return false; 408 | } 409 | } 410 | 411 | return true; 412 | } 413 | 414 | export default function applyLayoutProperties (node, oldProps, newProps, defaults) { 415 | if (isShallowEqual(oldProps, newProps)) { 416 | return false; 417 | } 418 | 419 | for (const propName in oldProps) { 420 | const propSetter = setterMap[propName]; 421 | 422 | if (propSetter && !newProps.hasOwnProperty(propName)) { 423 | 424 | const value = defaults && defaults.hasOwnProperty(propName) 425 | ? defaults[propName] 426 | : defaultValues[propName]; 427 | 428 | propSetter(node, value); 429 | } 430 | } 431 | 432 | for (const propName in newProps) { 433 | const propSetter = setterMap[propName]; 434 | 435 | if (propSetter) { 436 | propSetter(node, newProps[propName]); 437 | } 438 | } 439 | 440 | return true; 441 | } 442 | 443 | export function applyDefaultLayoutProperties (node) { 444 | for (const propName in defaultValues) { 445 | const propSetter = setterMap[propName]; 446 | 447 | if (propSetter) { 448 | propSetter(node, defaultValues[propName]); 449 | } 450 | } 451 | } 452 | -------------------------------------------------------------------------------- /src/elements/TextInput.js: -------------------------------------------------------------------------------- 1 | import { Container, Graphics, Text, TextStyle, TextMetrics } from 'pixi.js'; 2 | import BaseElement from './BaseElement'; 3 | import * as Yoga from 'typeflex'; 4 | 5 | const { MEASURE_MODE_EXACTLY } = Yoga; 6 | 7 | // MIT License 8 | 9 | // Copyright (c) 2018 Mwni 10 | 11 | // Permission is hereby granted, free of charge, to any person obtaining a copy 12 | // of this software and associated documentation files (the "Software"), to deal 13 | // in the Software without restriction, including without limitation the rights 14 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | // copies of the Software, and to permit persons to whom the Software is 16 | // furnished to do so, subject to the following conditions: 17 | 18 | // The above copyright notice and this permission notice shall be included in all 19 | // copies or substantial portions of the Software. 20 | 21 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 27 | // SOFTWARE. 28 | 29 | class PixiTextInput extends Container{ 30 | 31 | constructor (styles) { 32 | super(); 33 | 34 | this._inputStyle = Object.assign({ 35 | position: 'absolute', 36 | background: 'none', 37 | border: 'none', 38 | outline: 'none', 39 | transformOrigin: '0 0', 40 | lineHeight: '1' 41 | }, styles.input); 42 | 43 | if (styles.box) { 44 | this._boxGenerator = typeof styles.box === 'function' 45 | ? styles.box 46 | : new DefaultBoxGenerator(styles.box); 47 | } else { 48 | this._boxGenerator = null; 49 | } 50 | 51 | if (this._inputStyle.hasOwnProperty('multiline')) { 52 | this._multiline = Boolean(this._inputStyle.multiline); 53 | delete this._inputStyle.multiline; 54 | } else { 55 | this._multiline = false; 56 | } 57 | 58 | this._origRestrict = null; 59 | this._boxCache = {}; 60 | this._previous = {}; 61 | this._domAdded = false; 62 | this._domVisible = true; 63 | this._placeholder = ''; 64 | this._placeholderColor = 0xa9a9a9; 65 | this._selection = [ 0, 0 ]; 66 | this._restrictValue = ''; 67 | this._createDOMInput(); 68 | this.substituteText = true; 69 | this._setState('DEFAULT'); 70 | 71 | this.on('added', this._onAdded, this); 72 | this.on('removed', this._onRemoved, this); 73 | } 74 | 75 | get multiline () { 76 | return this._multiline; 77 | } 78 | 79 | set multiline (value) { 80 | if (this._multiline === value) { 81 | return; 82 | } 83 | 84 | this._multiline = value; 85 | 86 | this._domInput && this._createDOMInput(); 87 | this._update(); 88 | } 89 | 90 | get substituteText () { 91 | return this._substituted; 92 | } 93 | 94 | set substituteText (substitute) { 95 | if (this._substituted === substitute) { 96 | return; 97 | } 98 | 99 | this._substituted = substitute; 100 | 101 | if (substitute) { 102 | this._createSurrogate(); 103 | this._domVisible = false; 104 | } else { 105 | this._destroySurrogate(); 106 | this._domVisible = true; 107 | } 108 | 109 | this.placeholder = this._placeholder; 110 | this._update(); 111 | } 112 | 113 | get placeholder () { 114 | return this._placeholder; 115 | } 116 | 117 | set placeholder (text) { 118 | this._placeholder = text; 119 | 120 | if (this._substituted) { 121 | this._updateSurrogate(); 122 | this._domInput.placeholder = ''; 123 | } else { 124 | this._domInput.placeholder = text; 125 | } 126 | } 127 | 128 | get disabled () { 129 | return this._disabled; 130 | } 131 | 132 | set disabled (disabled) { 133 | this._disabled = disabled; 134 | this._domInput.disabled = disabled; 135 | this._setState(disabled ? 'DISABLED' : 'DEFAULT'); 136 | } 137 | 138 | get maxLength () { 139 | return this._maxLength; 140 | } 141 | 142 | set maxLength (length) { 143 | this._maxLength = length; 144 | this._domInput.setAttribute('maxlength', `${ length }`); 145 | } 146 | 147 | get restrict () { 148 | return this._restrictRegex; 149 | } 150 | 151 | set restrict (regex) { 152 | if (this._origRestrict === regex) { 153 | return; 154 | } 155 | 156 | this._origRestrict = regex; 157 | 158 | if (!regex) { 159 | this._restrictRegex = null; 160 | return; 161 | } 162 | 163 | if (regex instanceof RegExp) { 164 | regex = regex.toString().slice(1, -1); 165 | 166 | if (regex.charAt(0) !== '^') { 167 | regex = '^' + regex; 168 | } 169 | 170 | if (regex.charAt(regex.length-1) !== '$') { 171 | regex = regex + '$'; 172 | } 173 | 174 | regex = new RegExp(regex); 175 | } else { 176 | regex = new RegExp('^['+regex+']*$'); 177 | } 178 | 179 | this._restrictRegex = regex; 180 | } 181 | 182 | get text () { 183 | return this._domInput.value; 184 | } 185 | 186 | set text (text) { 187 | this._domInput.value = text; 188 | 189 | if (this._substituted) { 190 | this._updateSurrogate(); 191 | } 192 | } 193 | 194 | get htmlInput () { 195 | return this._domInput; 196 | } 197 | 198 | focus () { 199 | if (this._substituted && !this._domVisible) { 200 | this._setDOMInputVisible(true); 201 | } 202 | 203 | this._domInput.focus(); 204 | } 205 | 206 | blur () { 207 | this._domInput.blur(); 208 | } 209 | 210 | select () { 211 | this.focus(); 212 | this._domInput.select(); 213 | this._selection[0] = this._domInput.selectionStart; 214 | this._selection[1] = this._domInput.selectionEnd; 215 | } 216 | 217 | setInputStyle (key, value) { 218 | this._inputStyle[key] = value; 219 | this._domInput.style[key] = value; 220 | 221 | if(this._substituted && (key==='fontFamily' || key==='fontSize')) { 222 | this._updateFontMetrics(); 223 | } 224 | 225 | if (this._lastRenderer) { 226 | this._update(); 227 | } 228 | } 229 | 230 | destroy (options) { 231 | if (this._domAdded) { 232 | this._domInput.parentElement.removeChild(this._domInput); 233 | } 234 | 235 | this._destroyBoxCache(); 236 | super.destroy(options); 237 | } 238 | 239 | 240 | // SETUP 241 | 242 | _createDOMInput () { 243 | const isFocused = this.state === 'FOCUSED'; 244 | 245 | let parentElement = null; 246 | let text = ''; 247 | 248 | if (this._domInput) { 249 | this._domInput.removeEventListener('keydown', this._onInputKeyDown); 250 | this._domInput.removeEventListener('input', this._onInputInput); 251 | this._domInput.removeEventListener('keyup', this._onInputKeyUp); 252 | this._domInput.removeEventListener('focus', this._onFocused); 253 | this._domInput.removeEventListener('blur', this._onBlurred); 254 | text = this._domInput.value; 255 | parentElement = this._domInput.parentElement; 256 | parentElement && parentElement.removeChild(this._domInput); 257 | } 258 | 259 | if (this._multiline) { 260 | this._domInput = document.createElement('textarea'); 261 | this._domInput.style.resize = 'none'; 262 | } else { 263 | this._domInput = document.createElement('input'); 264 | this._domInput.type = 'text'; 265 | } 266 | 267 | for (let key in this._inputStyle) { 268 | this._domInput.style[key] = this._inputStyle[key]; 269 | } 270 | 271 | this._domInput.value = text; 272 | 273 | parentElement && parentElement.appendChild(this._domInput); 274 | isFocused && this._domInput.focus(); 275 | 276 | const isSelected = this._selection[0] === 0 && this._selection[1] === this._domInput.value.length; 277 | 278 | if (isSelected) { 279 | this._domInput.select(); 280 | } else { 281 | this._domInput.setSelectionRange(this._selection[0], this._selection[1]); 282 | } 283 | 284 | this._domInput.addEventListener('keydown', this._onInputKeyDown); 285 | this._domInput.addEventListener('input', this._onInputInput); 286 | this._domInput.addEventListener('keyup', this._onInputKeyUp); 287 | this._domInput.addEventListener('focus', this._onFocused); 288 | this._domInput.addEventListener('blur', this._onBlurred); 289 | } 290 | 291 | _onInputKeyDown = (e) => { 292 | this._selection[0] = this._domInput.selectionStart; 293 | this._selection[1] = this._domInput.selectionEnd; 294 | this.emit('keydown', e.keyCode); 295 | }; 296 | 297 | _onInputInput = (e) => { 298 | if (this._restrictRegex) { 299 | this._applyRestriction(); 300 | } 301 | 302 | if(this._substituted) { 303 | this._updateSubstitution(); 304 | } 305 | 306 | this.emit('input', this.text); 307 | }; 308 | 309 | _onInputKeyUp = (e) => { 310 | this.emit('keyup', e.keyCode); 311 | }; 312 | 313 | _onFocused = () => { 314 | this._setState('FOCUSED'); 315 | this.emit('focus'); 316 | }; 317 | 318 | _onBlurred = () => { 319 | this._setState('DEFAULT'); 320 | this.emit('blur'); 321 | }; 322 | 323 | _onAdded () { 324 | this._domInput.style.display = 'none'; 325 | document.body.appendChild(this._domInput); 326 | this._domAdded = true; 327 | } 328 | 329 | _onRemoved () { 330 | document.body.removeChild(this._domInput); 331 | this._domAdded = false; 332 | } 333 | 334 | _setState (state) { 335 | this.state = state; 336 | this._updateBox(); 337 | 338 | if (this._substituted) { 339 | this._updateSubstitution(); 340 | } 341 | } 342 | 343 | // RENDER & UPDATE 344 | 345 | // for pixi v4 346 | renderWebGL (renderer) { 347 | super.renderWebGL(renderer); 348 | this._renderInternal(renderer); 349 | } 350 | 351 | // for pixi v4 352 | renderCanvas (renderer) { 353 | super.renderCanvas(renderer); 354 | this._renderInternal(renderer); 355 | } 356 | 357 | // for pixi v5 358 | render (renderer) { 359 | super.render(renderer); 360 | this._renderInternal(renderer); 361 | } 362 | 363 | _renderInternal (renderer) { 364 | this._resolution = renderer.resolution; 365 | this._lastRenderer = renderer; 366 | this._canvasBounds = this._getCanvasBounds(); 367 | if(this._needsUpdate()) { 368 | this._update(); 369 | } 370 | } 371 | 372 | _update(){ 373 | this._updateDOMInput(); 374 | 375 | if (this._substituted) { 376 | this._updateSurrogate(); 377 | } 378 | 379 | this._updateBox(); 380 | } 381 | 382 | _updateBox () { 383 | if (!this._boxGenerator) { 384 | return; 385 | } 386 | 387 | if (this._needsNewBoxCache()) { 388 | this._buildBoxCache(); 389 | } 390 | 391 | if (this.state === this._previous.state && this._box === this._boxCache[this.state]) { 392 | return; 393 | } 394 | 395 | if (this._box) { 396 | this.removeChild(this._box); 397 | } 398 | 399 | this._box = this._boxCache[this.state]; 400 | this.addChildAt(this._box, 0); 401 | this._previous.state = this.state; 402 | } 403 | 404 | _updateSubstitution () { 405 | if (this.state==='FOCUSED') { 406 | this._domVisible = true; 407 | this._surrogate.visible = this.text.length === 0; 408 | } else { 409 | this._domVisible = false; 410 | this._surrogate.visible = true; 411 | } 412 | 413 | this._updateDOMInput(); 414 | this._updateSurrogate(); 415 | } 416 | 417 | _updateDOMInput () { 418 | if (!this._canvasBounds) { 419 | return; 420 | } 421 | 422 | this._domInput.style.top = (this._canvasBounds.top || 0) + 'px'; 423 | this._domInput.style.left = (this._canvasBounds.left || 0) + 'px'; 424 | this._domInput.style.transform = this._pixiMatrixToCSS(this._getDOMRelativeWorldTransform()); 425 | this._domInput.style.opacity = this.worldAlpha; 426 | this._setDOMInputVisible(this.worldVisible && this._domVisible); 427 | 428 | this._previous.canvasBounds = this._canvasBounds; 429 | this._previous.worldTransform = this.worldTransform.clone(); 430 | this._previous.worldAlpha = this.worldAlpha; 431 | this._previous.worldVisible = this.worldVisible; 432 | } 433 | 434 | _applyRestriction () { 435 | if (this._restrictRegex.test(this.text)) { 436 | this._restrictValue = this.text; 437 | } else { 438 | this.text = this._restrictValue; 439 | this._domInput.setSelectionRange(this._selection[0], this._selection[1]); 440 | } 441 | } 442 | 443 | // STATE COMPAIRSON (FOR PERFORMANCE BENEFITS) 444 | 445 | _needsUpdate () { 446 | return ( 447 | !this._comparePixiMatrices(this.worldTransform, this._previous.worldTransform) 448 | || !this._compareClientRects(this._canvasBounds, this._previous.canvasBounds) 449 | || this.worldAlpha !== this._previous.worldAlpha 450 | || this.worldVisible !== this._previous.worldVisible 451 | ); 452 | } 453 | 454 | _needsNewBoxCache () { 455 | const inputBounds = this._getDOMInputBounds(); 456 | 457 | return ( 458 | !this._previous.inputBounds 459 | || inputBounds.width != this._previous.inputBounds.width 460 | || inputBounds.height != this._previous.inputBounds.height 461 | ); 462 | } 463 | 464 | 465 | // INPUT SUBSTITUTION 466 | 467 | _createSurrogate () { 468 | this._surrogateHitbox = new Graphics(); 469 | this._surrogateHitbox.alpha = 0; 470 | this._surrogateHitbox.interactive = true; 471 | this._surrogateHitbox.cursor = 'text'; 472 | this._surrogateHitbox.on('pointerdown', this._onSurrogateFocus, this); 473 | this.addChild(this._surrogateHitbox); 474 | 475 | this._surrogateMask = new Graphics(); 476 | this.addChild(this._surrogateMask); 477 | 478 | this._surrogate = new Text('', {}); 479 | this.addChild(this._surrogate); 480 | 481 | this._surrogate.mask = this._surrogateMask; 482 | 483 | this._updateFontMetrics(); 484 | this._updateSurrogate(); 485 | } 486 | 487 | _updateSurrogate () { 488 | let padding = this._deriveSurrogatePadding(); 489 | let inputBounds = this._getDOMInputBounds(); 490 | 491 | this._surrogate.style = this._deriveSurrogateStyle(); 492 | this._surrogate.style.padding = Math.max.apply(Math, padding); 493 | this._surrogate.y = this._multiline ? padding[0] : (inputBounds.height - this._surrogate.height) / 2; 494 | this._surrogate.x = padding[3]; 495 | this._surrogate.text = this._deriveSurrogateText(); 496 | 497 | switch (this._surrogate.style.align) { 498 | 499 | case 'left': 500 | this._surrogate.x = padding[3]; 501 | break; 502 | 503 | case 'center': 504 | this._surrogate.x = inputBounds.width * 0.5 - this._surrogate.width * 0.5; 505 | break; 506 | 507 | case 'right': 508 | this._surrogate.x = inputBounds.width - padding[1] - this._surrogate.width; 509 | break; 510 | } 511 | 512 | this._updateSurrogateHitbox(inputBounds); 513 | this._updateSurrogateMask(inputBounds, padding); 514 | } 515 | 516 | _updateSurrogateHitbox (bounds) { 517 | this._surrogateHitbox.clear(); 518 | this._surrogateHitbox.beginFill(0); 519 | this._surrogateHitbox.drawRect(0, 0, bounds.width, bounds.height); 520 | this._surrogateHitbox.endFill(); 521 | this._surrogateHitbox.interactive = !this._disabled; 522 | } 523 | 524 | _updateSurrogateMask(bounds, padding) { 525 | this._surrogateMask.clear(); 526 | this._surrogateMask.beginFill(0); 527 | this._surrogateMask.drawRect(padding[3], 0, bounds.width - padding[3] - padding[1], bounds.height); 528 | this._surrogateMask.endFill(); 529 | } 530 | 531 | _destroySurrogate () { 532 | if (!this._surrogate) { 533 | return; 534 | } 535 | 536 | this.removeChild(this._surrogate); 537 | this.removeChild(this._surrogateHitbox); 538 | 539 | this._surrogate.destroy(); 540 | this._surrogateHitbox.destroy(); 541 | 542 | this._surrogate = null; 543 | this._surrogateHitbox = null; 544 | } 545 | 546 | _onSurrogateFocus () { 547 | this._setDOMInputVisible(true); 548 | // sometimes the input is not being focused by the mouseclick 549 | setTimeout(() => this._ensureFocus(), 10); 550 | } 551 | 552 | _ensureFocus () { 553 | if (!this._hasFocus()) { 554 | this.focus(); 555 | } 556 | } 557 | 558 | _deriveSurrogateStyle () { 559 | const style = new TextStyle(); 560 | 561 | for (const key in this._inputStyle) { 562 | switch (key) { 563 | 564 | case 'color': 565 | style.fill = this._inputStyle.color; 566 | break; 567 | 568 | case 'fontFamily': 569 | case 'fontSize': 570 | case 'fontWeight': 571 | case 'fontVariant': 572 | case 'fontStyle': 573 | style[key] = this._inputStyle[key]; 574 | break; 575 | 576 | case 'letterSpacing': 577 | style.letterSpacing = parseFloat(this._inputStyle.letterSpacing); 578 | break; 579 | 580 | case 'textAlign': 581 | style.align = this._inputStyle.textAlign; 582 | break; 583 | } 584 | } 585 | 586 | if (this._multiline) { 587 | style.lineHeight = parseFloat(style.fontSize); 588 | style.wordWrap = true; 589 | style.breakWords = true; 590 | style.wordWrapWidth = this._getDOMInputBounds().width; 591 | } 592 | 593 | if (this._domInput.value.length === 0) { 594 | style.fill = this._placeholderColor; 595 | } 596 | 597 | return style; 598 | } 599 | 600 | _deriveSurrogatePadding () { 601 | const indent = this._inputStyle.textIndent ? parseFloat(this._inputStyle.textIndent) : 0; 602 | 603 | if (this._inputStyle.padding && this._inputStyle.padding.length > 0) { 604 | 605 | const components = this._inputStyle.padding.trim().split(' '); 606 | const componentCount = components.length; 607 | 608 | if (componentCount === 1) { 609 | const padding = parseFloat(components[0]); 610 | return [ padding, padding, padding, padding + indent ]; 611 | } 612 | 613 | if (componentCount === 2) { 614 | const paddingV = parseFloat(components[0]); 615 | const paddingH = parseFloat(components[1]); 616 | return [ paddingV, paddingH, paddingV, paddingH + indent ]; 617 | } 618 | 619 | if (componentCount === 4) { 620 | const padding = components.map((component) => parseFloat(component)); 621 | padding[3] += indent; 622 | return padding; 623 | } 624 | } 625 | 626 | return [ 0, 0, 0, indent ]; 627 | } 628 | 629 | _deriveSurrogateText () { 630 | const inputLength = this._domInput.value.length; 631 | 632 | if (inputLength === 0) { 633 | return this._placeholder; 634 | } 635 | 636 | if (this._domInput.type == 'password') { 637 | return '•'.repeat(inputLength); 638 | } 639 | 640 | return this._domInput.value; 641 | } 642 | 643 | _updateFontMetrics () { 644 | const style = this._deriveSurrogateStyle(); 645 | const font = style.toFontString(); 646 | 647 | this._fontMetrics = TextMetrics.measureFont(font); 648 | } 649 | 650 | 651 | // CACHING OF INPUT BOX GRAPHICS 652 | 653 | _buildBoxCache () { 654 | this._destroyBoxCache(); 655 | 656 | const states = [ 'DEFAULT','FOCUSED', 'DISABLED' ]; 657 | const inputBounds = this._getDOMInputBounds(); 658 | 659 | for (let i in states) { 660 | this._boxCache[states[i]] = this._boxGenerator(inputBounds.width, inputBounds.height, states[i]); 661 | } 662 | 663 | this._previous.inputBounds = inputBounds; 664 | } 665 | 666 | _destroyBoxCache () { 667 | if (this._box) { 668 | this.removeChild(this._box); 669 | this._box = null; 670 | } 671 | 672 | for (let i in this._boxCache) { 673 | this._boxCache[i].destroy(); 674 | this._boxCache[i] = null; 675 | delete this._boxCache[i]; 676 | } 677 | } 678 | 679 | // HELPER FUNCTIONS 680 | 681 | _hasFocus () { 682 | return document.activeElement === this._domInput; 683 | } 684 | 685 | _setDOMInputVisible (visible) { 686 | this._domInput.style.display = visible ? 'block' : 'none'; 687 | } 688 | 689 | _getCanvasBounds () { 690 | const rect = this._lastRenderer.view.getBoundingClientRect(); 691 | const bounds = { top: rect.top, left: rect.left, width: rect.width, height: rect.height }; 692 | bounds.left += window.scrollX; 693 | bounds.top += window.scrollY; 694 | return bounds; 695 | } 696 | 697 | _getDOMInputBounds () { 698 | const removeAfter = !this._domAdded; 699 | 700 | removeAfter && document.body.appendChild(this._domInput); 701 | 702 | const orgTransform = this._domInput.style.transform; 703 | const orgDisplay = this._domInput.style.display; 704 | 705 | this._domInput.style.transform = ''; 706 | this._domInput.style.display = 'block'; 707 | 708 | const bounds = this._domInput.getBoundingClientRect(); 709 | 710 | this._domInput.style.transform = orgTransform; 711 | this._domInput.style.display = orgDisplay; 712 | 713 | removeAfter && document.body.removeChild(this._domInput); 714 | 715 | return bounds; 716 | } 717 | 718 | _getDOMRelativeWorldTransform () { 719 | const canvasBounds = this._lastRenderer.view.getBoundingClientRect(); 720 | const matrix = this.worldTransform.clone(); 721 | 722 | matrix.scale(this._resolution, this._resolution); 723 | matrix.scale(canvasBounds.width / this._lastRenderer.width, canvasBounds.height / this._lastRenderer.height); 724 | 725 | return matrix; 726 | } 727 | 728 | _pixiMatrixToCSS (m) { 729 | return `matrix(${ m.a },${ m.b },${ m.c },${ m.d },${ m.tx },${ m.ty })`; 730 | } 731 | 732 | _comparePixiMatrices (m1, m2) { 733 | if (!m1 || !m2) { 734 | return false; 735 | } 736 | 737 | return ( 738 | m1.a === m2.a 739 | && m1.b === m2.b 740 | && m1.c === m2.c 741 | && m1.d === m2.d 742 | && m1.tx === m2.tx 743 | && m1.ty === m2.ty 744 | ); 745 | } 746 | 747 | _compareClientRects (r1, r2) { 748 | return Boolean(r1) && Boolean(r2) && r1.left == r2.left && r1.top == r2.top && r1.width == r2.width && r1.height == r2.height; 749 | } 750 | 751 | } 752 | 753 | 754 | function DefaultBoxGenerator (styles) { 755 | styles = styles || { fill: 0xcccccc }; 756 | 757 | if (styles.default) { 758 | styles.focused = styles.focused || styles.default; 759 | styles.disabled = styles.disabled || styles.default; 760 | } else { 761 | styles = { default: styles, focused: styles, disabled: styles }; 762 | } 763 | 764 | return function (w, h, state) { 765 | const style = styles[state.toLowerCase()]; 766 | const box = new Graphics(); 767 | 768 | if (style.fill) { 769 | box.beginFill(style.fill); 770 | } 771 | 772 | if (style.stroke) { 773 | box.lineStyle(style.stroke.width || 1, style.stroke.color || 0, style.stroke.alpha || 1); 774 | } 775 | 776 | if (style.rounded) { 777 | box.drawRoundedRect(0, 0, w, h, style.rounded); 778 | } else { 779 | box.drawRect(0, 0, w, h); 780 | } 781 | 782 | box.endFill(); 783 | box.closePath(); 784 | 785 | return box; 786 | } 787 | } 788 | 789 | // React Element 790 | 791 | export default class TextInput extends BaseElement { 792 | 793 | onBlur = () => { 794 | this.onBlurCallback && this.onBlurCallback(); 795 | }; 796 | 797 | onFocus = () => { 798 | this.onFocusCallback && this.onFocusCallback(); 799 | }; 800 | 801 | onInput = (text) => { 802 | this.onInputCallback && this.onInputCallback(text); 803 | }; 804 | 805 | onKeyDown = (keyCode) => { 806 | this.onKeyDownCallback && this.onKeyDownCallback(keyCode); 807 | }; 808 | 809 | onKeyUp = (keyCode) => { 810 | this.onKeyUpCallback && this.onKeyUpCallback(keyCode); 811 | }; 812 | 813 | constructor (props, root) { 814 | super(props, root); 815 | this.sizeData = { width: 0, height: 0 }; 816 | this.onBlurCallback = null; 817 | this.onFocusCallback = null; 818 | this.onInputCallback = null; 819 | this.onKeyDownCallback = null; 820 | this.onKeyUpCallback = null; 821 | this.displayObject.on('focus', this.onFocus, this); 822 | this.displayObject.on('blur', this.onBlur, this); 823 | this.displayObject.on('input', this.onInput, this); 824 | this.displayObject.on('keydown', this.onKeyDown, this); 825 | this.displayObject.on('keyup', this.onKeyUp, this); 826 | } 827 | 828 | createDisplayObject () { 829 | return new PixiTextInput({ input: {}, box: {} }); 830 | } 831 | 832 | applyProps (oldProps, newProps) { 833 | const { 834 | fontSize: previousFontSize, 835 | fontFamily: previousFontFamily, 836 | fontWeight: previousFontWeight 837 | } = this.style; 838 | 839 | super.applyProps(oldProps, newProps); 840 | 841 | this.onBlurCallback = newProps.onBlur; 842 | this.onFocusCallback = newProps.onFocus; 843 | this.onInputCallback = newProps.onInput; 844 | this.onKeyDownCallback = newProps.onKeyDown; 845 | this.onKeyUpCallback = newProps.onKeyUp; 846 | 847 | const { 848 | color = 'black', 849 | textAlign = 'left', 850 | fontFamily = 'sans-serif', 851 | fontSize = 32, 852 | fontWeight = 'normal', 853 | multiline = false 854 | } = this.style; 855 | 856 | const { placeholder = '', text, inputType='text', maxLength = -1, restrict = null } = newProps; 857 | 858 | if (multiline) { 859 | this.displayObject.htmlInput.setAttribute('type', inputType); 860 | } 861 | 862 | if (maxLength >= 0) { 863 | this.displayObject.maxLength = maxLength; 864 | } 865 | 866 | const isLayoutDirty = this.displayObject.text !== text 867 | || this.displayObject.placeHolder !== placeholder 868 | || previousFontSize !== fontSize 869 | || previousFontFamily !== fontFamily 870 | || previousFontWeight !== fontWeight; 871 | 872 | 873 | if (text !== undefined) { 874 | this.displayObject.text = text; 875 | } 876 | 877 | this.displayObject.restrict = restrict; 878 | this.displayObject.placeholder = placeholder; 879 | this.displayObject.setInputStyle('textAlign', textAlign); 880 | this.displayObject.setInputStyle('color', color); 881 | this.displayObject.setInputStyle('fontSize', `${ fontSize }px`); 882 | this.displayObject.setInputStyle('fontFamily', fontFamily); 883 | this.displayObject.setInputStyle('fontWeight', fontWeight); 884 | this.displayObject.multiline = multiline; 885 | 886 | if (isLayoutDirty) { 887 | this.layoutNode.markDirty(); 888 | this.layoutDirty = true; 889 | } 890 | } 891 | 892 | destroy () { 893 | this.displayObject.removeListener('focus', this.onFocus, this); 894 | this.displayObject.removeListener('blur', this.onBlur, this); 895 | this.displayObject.removeListener('input', this.onInput, this); 896 | this.displayObject.removeListener('keydown', this.onKeyDown, this); 897 | this.displayObject.removeListener('keyup', this.onKeyUp, this); 898 | super.destroy(); 899 | } 900 | 901 | measure (node, width, widthMode, height, heightMode) { 902 | const input = this.displayObject.htmlInput; 903 | 904 | const inputHeight = input.style.width; 905 | const inputWidth = input.style.width; 906 | 907 | input.style.height = heightMode === MEASURE_MODE_EXACTLY ? `${ height }px` : 'auto'; 908 | input.style.width = widthMode === MEASURE_MODE_EXACTLY ? `${ width }px` : 'auto'; 909 | 910 | const boundingRect = this.displayObject._getDOMInputBounds(); 911 | this.sizeData.width = boundingRect.width; 912 | this.sizeData.height = boundingRect.height; 913 | 914 | input.style.height = inputHeight; 915 | input.style.width = inputWidth; 916 | 917 | return this.sizeData; 918 | } 919 | 920 | onLayout (x, y, width, height) { 921 | this.displayObject.pivot.x = this.anchorX * width; 922 | this.displayObject.pivot.y = this.anchorY * height; 923 | this.displayObject.setInputStyle('width', `${ width }px`); 924 | this.displayObject.setInputStyle('height', `${ height }px`); 925 | } 926 | 927 | } 928 | -------------------------------------------------------------------------------- /src/elements/BitmapTextContainer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * ------------------------------------------------------------------------------ 3 | * Pixi port based on 4 | * https://github.com/BowlerHatLLC/feathers/blob/1b2fdd9/source/feathers/controls/text/BitmapFontTextRenderer.as 5 | * Copyright 2012-2016 Bowler Hat LLC. All rights reserved. 6 | * Licensed under the Simplified BSD License 7 | * https://github.com/BowlerHatLLC/feathers/blob/master/LICENSE.md 8 | * ------------------------------------------------------------------------------ 9 | */ 10 | 11 | import { BitmapFont, Container, Point, Sprite } from 'pixi.js'; 12 | 13 | const CHARACTER_ID_SPACE = 32; 14 | const CHARACTER_ID_TAB = 9; 15 | const CHARACTER_ID_LINE_FEED = 10; 16 | const CHARACTER_ID_CARRIAGE_RETURN = 13; 17 | const CHARACTER_BUFFER = []; 18 | const FUZZY_MAX_WIDTH_PADDING = 0.000001; 19 | 20 | const characterViewPool = []; 21 | let characterViewPoolIndex = -1; 22 | 23 | const charLocationPool = []; 24 | let charLocationPoolIndex = -1; 25 | 26 | const HELPER_RESULT = { 27 | isTruncated: false, 28 | width: 0, 29 | height: 0 30 | }; 31 | 32 | const Align = { 33 | CENTER: 'center', 34 | END: 'end', 35 | JUSTIFY: 'justify', 36 | LEFT: 'left', 37 | RIGHT: 'right', 38 | START: 'start', 39 | BOTTOM: 'bottom', 40 | TOP: 'top' 41 | }; 42 | 43 | const { abs, round } = Math; 44 | 45 | export default class BitmapTextContainer extends Container { 46 | 47 | constructor () { 48 | super(); 49 | 50 | this._batchX = 0; 51 | this._verticalAlignOffsetY = 0; 52 | this._maxWidth = 0; 53 | this._numLines = 0; 54 | this._truncateToFit = false; 55 | this._truncationText = '...'; 56 | this._lastLayoutWidth = Infinity; 57 | this._lastLayoutExplicitWidth = 0; 58 | this._lastLayoutHeight = 0; 59 | this._lastLayoutIsTruncated = false; 60 | this._wordWrap = false; 61 | this._textDirty = false; 62 | 63 | this._font = null; 64 | this._fontData = null; 65 | this._size = 16; 66 | this._color = 0xffffff; 67 | this._align = Align.LEFT; 68 | this._leading = 0; 69 | this._letterSpacing = 0; 70 | this._isKerningEnabled = true; 71 | this._verticalAlign = Align.TOP; 72 | 73 | this._activeCharacters = []; 74 | this._activeCharacterCount = 0; 75 | 76 | this._width = 0; 77 | this._height = 0; 78 | } 79 | 80 | measureText (result = new Point(), width = this._width) { 81 | 82 | if (!this._fontData) { 83 | this._fontData = BitmapFont.available[this._font]; 84 | } 85 | 86 | if (!this._fontData || !this._text) { 87 | result.x = 0; 88 | result.y = 0; 89 | return result; 90 | } 91 | 92 | const font = this._fontData; 93 | const customSize = this._size; 94 | const customLetterSpacing = this._letterSpacing; 95 | const isKerningEnabled = this._isKerningEnabled; 96 | let scale = customSize / font.size; 97 | 98 | if (isNaN(scale)) { 99 | scale = 1; 100 | } 101 | 102 | const lineHeight = font.lineHeight * scale + this._leading; 103 | const maxLineWidth = isNaN(width) ? this._explicitMaxWidth : width; 104 | 105 | let maxX = 0; 106 | let currentX = 0; 107 | let currentY = 0; 108 | let previousCharId = NaN; 109 | let charCount = this._text.length; 110 | let startXOfPreviousWord = 0; 111 | let widthOfWhitespaceAfterWord = 0; 112 | let wordCountForLine = 0; 113 | 114 | for (let i = 0; i < charCount; i++) { 115 | const charId = this._text.charCodeAt(i); 116 | if (charId === CHARACTER_ID_LINE_FEED || charId === CHARACTER_ID_CARRIAGE_RETURN) { //new line \n or \r 117 | currentX = currentX - customLetterSpacing; 118 | if (currentX < 0) { 119 | currentX = 0; 120 | } 121 | if (maxX < currentX) { 122 | maxX = currentX; 123 | } 124 | previousCharId = NaN; 125 | currentX = 0; 126 | currentY += lineHeight; 127 | startXOfPreviousWord = 0; 128 | wordCountForLine = 0; 129 | widthOfWhitespaceAfterWord = 0; 130 | continue; 131 | } 132 | 133 | const charData = font.chars[charId]; 134 | 135 | if (!charData) { 136 | console.warn(`Missing character ${ String.fromCharCode(charId) } in font ${ font.name }.`); 137 | continue; 138 | } 139 | 140 | if (isKerningEnabled && !isNaN(previousCharId)) { 141 | currentX += (charData.kerning[previousCharId] || 0) * scale; 142 | } 143 | 144 | const xAdvance = charData.xAdvance * scale; 145 | 146 | if (this._wordWrap) { 147 | const currentCharIsWhitespace = charId === CHARACTER_ID_SPACE || charId === CHARACTER_ID_TAB; 148 | const previousCharIsWhitespace = previousCharId === CHARACTER_ID_SPACE || previousCharId === CHARACTER_ID_TAB; 149 | 150 | if (currentCharIsWhitespace) { 151 | if (!previousCharIsWhitespace) { 152 | widthOfWhitespaceAfterWord = 0; 153 | } 154 | widthOfWhitespaceAfterWord += xAdvance; 155 | } 156 | else if (previousCharIsWhitespace) { 157 | startXOfPreviousWord = currentX; 158 | wordCountForLine++; 159 | } 160 | 161 | if (!currentCharIsWhitespace && wordCountForLine > 0 && (currentX + xAdvance) > maxLineWidth) { 162 | widthOfWhitespaceAfterWord = startXOfPreviousWord - widthOfWhitespaceAfterWord; 163 | 164 | if (maxX < widthOfWhitespaceAfterWord) { 165 | maxX = widthOfWhitespaceAfterWord; 166 | } 167 | 168 | previousCharId = NaN; 169 | currentX -= startXOfPreviousWord; 170 | currentY += lineHeight; 171 | startXOfPreviousWord = 0; 172 | widthOfWhitespaceAfterWord = 0; 173 | wordCountForLine = 0; 174 | } 175 | } 176 | 177 | currentX += xAdvance + customLetterSpacing; 178 | previousCharId = charId; 179 | } 180 | 181 | currentX = currentX - customLetterSpacing; 182 | 183 | if (currentX < 0) { 184 | currentX = 0; 185 | } 186 | 187 | // if the text ends in extra whitespace, the currentX value will be 188 | // larger than the max line width. we'll remove that and add extra 189 | // lines. 190 | 191 | if (this._wordWrap) { 192 | while (currentX > maxLineWidth) { 193 | currentX -= maxLineWidth; 194 | currentY += lineHeight; 195 | if (maxLineWidth === 0) { 196 | //we don't want to get stuck in an infinite loop! 197 | break; 198 | } 199 | } 200 | } 201 | 202 | if (maxX < currentX) { 203 | maxX = currentX; 204 | } 205 | 206 | result.x = maxX; 207 | result.y = currentY + lineHeight - this._leading; 208 | 209 | return result; 210 | } 211 | 212 | invalidate () { 213 | this._isInvalid = true; 214 | // TODO: TEMPORARY, fix this 215 | this.validate(); 216 | } 217 | 218 | validate () { 219 | this._fontData = BitmapFont.available[this._font]; 220 | 221 | if (!this._fontData) { 222 | return; 223 | } 224 | 225 | this._isInvalid = false; 226 | this.draw(); 227 | } 228 | 229 | 230 | draw () { 231 | let sizeInvalid = this._textDirty; 232 | 233 | this._textDirty = false; 234 | 235 | // sometimes, we can determine that the layout will be exactly 236 | // the same without needing to update. this will result in much 237 | // better performance. 238 | let newWidth = this._width; 239 | 240 | if (isNaN(newWidth)) { 241 | newWidth = this._explicitMaxWidth; 242 | } 243 | 244 | // sometimes, we can determine that the dimensions will be exactly 245 | // the same without needing to refresh the text lines. this will 246 | // result in much better performance. 247 | if (this._wordWrap) { 248 | // when word wrapped, we need to measure again any time that the 249 | // width changes. 250 | sizeInvalid = sizeInvalid || newWidth !== this._lastLayoutWidth || this._width !== this._lastLayoutExplicitWidth; 251 | } else { 252 | //we can skip measuring again more frequently when the text is 253 | //a single line. 254 | 255 | //if the width is smaller than the last layout width, we need to 256 | //measure again. when it's larger, the result won't change... 257 | sizeInvalid = sizeInvalid || newWidth < this._lastLayoutWidth; 258 | 259 | //...unless the text was previously truncated! 260 | sizeInvalid = sizeInvalid || (this._lastLayoutIsTruncated && newWidth !== this._lastLayoutWidth); 261 | 262 | //... or the text is aligned 263 | sizeInvalid = sizeInvalid || this._align !== Align.LEFT; 264 | } 265 | 266 | if (sizeInvalid) { 267 | 268 | for (let i = this._activeCharacterCount - 1; i >= 0; i--) { 269 | const charView = this._activeCharacters[i]; 270 | this._activeCharacters[i] = null; 271 | this.removeChild(charView); 272 | characterViewPool[++characterViewPoolIndex] = charView; 273 | } 274 | 275 | this._activeCharacterCount = 0; 276 | 277 | if (!this._text) { 278 | return; 279 | } 280 | 281 | this.layoutCharacters(HELPER_RESULT); 282 | this._lastLayoutExplicitWidth = this._width; 283 | this._lastLayoutWidth = HELPER_RESULT.width; 284 | this._lastLayoutHeight = HELPER_RESULT.height; 285 | this._lastLayoutIsTruncated = HELPER_RESULT.isTruncated; 286 | } 287 | } 288 | 289 | layoutCharacters (result) { 290 | this._numLines = 1; 291 | 292 | const font = this._fontData; 293 | const customSize = this._size; 294 | const customLetterSpacing = this._letterSpacing; 295 | const isKerningEnabled = this._isKerningEnabled; 296 | 297 | let scale = customSize / font.size; 298 | 299 | if (isNaN(scale)) { 300 | scale = 1; 301 | } 302 | 303 | const lineHeight = font.lineHeight * scale + this._leading; 304 | const hasExplicitWidth = !isNaN(this._width); 305 | const isAligned = this._align !== Align.LEFT; 306 | 307 | let maxLineWidth = hasExplicitWidth ? this._width : this._explicitMaxWidth; 308 | 309 | if (isAligned && maxLineWidth === Number.POSITIVE_INFINITY) { 310 | // we need to measure the text to get the maximum line width 311 | // so that we can align the text 312 | // this.measureText(HELPER_POINT); 313 | maxLineWidth = this._width; 314 | } 315 | 316 | let textToDraw = this._text; 317 | 318 | if (this._truncateToFit) { 319 | const truncatedText = this.getTruncatedText(maxLineWidth); 320 | result.isTruncated = truncatedText !== textToDraw; 321 | textToDraw = truncatedText; 322 | } else { 323 | result.isTruncated = false; 324 | } 325 | 326 | CHARACTER_BUFFER.length = 0; 327 | 328 | let maxX = 0; 329 | let currentX = 0; 330 | let currentY = 0; 331 | let previousCharId = NaN; 332 | let isWordComplete = false; 333 | let startXOfPreviousWord = 0; 334 | let widthOfWhitespaceAfterWord = 0; 335 | let wordLength = 0; 336 | let wordCountForLine = 0; 337 | let charCount = textToDraw ? textToDraw.length : 0; 338 | 339 | for (let i = 0; i < charCount; i++) { 340 | isWordComplete = false; 341 | const charId = textToDraw.charCodeAt(i); 342 | if (charId === CHARACTER_ID_LINE_FEED || charId === CHARACTER_ID_CARRIAGE_RETURN) { //new line \n or \r 343 | currentX = currentX - customLetterSpacing; 344 | if (currentX < 0) { 345 | currentX = 0; 346 | } 347 | 348 | if (this._wordWrap || isAligned) { 349 | this.alignBuffer(maxLineWidth, currentX, 0); 350 | this.addBufferToBatch(0); 351 | } 352 | 353 | if (maxX < currentX) { 354 | maxX = currentX; 355 | } 356 | 357 | previousCharId = NaN; 358 | currentX = 0; 359 | currentY += lineHeight; 360 | startXOfPreviousWord = 0; 361 | widthOfWhitespaceAfterWord = 0; 362 | wordLength = 0; 363 | wordCountForLine = 0; 364 | this._numLines++; 365 | continue; 366 | } 367 | 368 | const charData = font.chars[charId]; 369 | 370 | if (!charData) { 371 | console.warn(`Missing character ${ String.fromCharCode(charId) } in font ${ font.name }.`); 372 | continue; 373 | } 374 | 375 | if (isKerningEnabled && !isNaN(previousCharId)) { 376 | currentX += (charData.kerning[previousCharId] || 0) * scale; 377 | } 378 | 379 | const xAdvance = charData.xAdvance * scale; 380 | 381 | if (this._wordWrap) { 382 | const currentCharIsWhitespace = charId === CHARACTER_ID_SPACE || charId === CHARACTER_ID_TAB; 383 | const previousCharIsWhitespace = previousCharId === CHARACTER_ID_SPACE || previousCharId === CHARACTER_ID_TAB; 384 | 385 | if (currentCharIsWhitespace) { 386 | if (!previousCharIsWhitespace) { 387 | widthOfWhitespaceAfterWord = 0; 388 | } 389 | widthOfWhitespaceAfterWord += xAdvance; 390 | } 391 | else if (previousCharIsWhitespace) { 392 | startXOfPreviousWord = currentX; 393 | wordLength = 0; 394 | wordCountForLine++; 395 | isWordComplete = true; 396 | } 397 | 398 | // we may need to move to a new line at the same time 399 | // that our previous word in the buffer can be batched 400 | // so we need to add the buffer here rather than after 401 | // the next section 402 | if (isWordComplete && !isAligned) { 403 | this.addBufferToBatch(0); 404 | } 405 | 406 | // floating point errors can cause unnecessary line breaks, 407 | // so we're going to be a little bit fuzzy on the greater 408 | // than check. such tiny numbers shouldn't break anything. 409 | if (!currentCharIsWhitespace && wordCountForLine > 0 && ((currentX + xAdvance) - maxLineWidth) > FUZZY_MAX_WIDTH_PADDING) { 410 | if (isAligned) { 411 | this.trimBuffer(wordLength); 412 | this.alignBuffer(maxLineWidth, startXOfPreviousWord - widthOfWhitespaceAfterWord, wordLength); 413 | this.addBufferToBatch(wordLength); 414 | } 415 | this.moveBufferedCharacters(-startXOfPreviousWord, lineHeight, 0); 416 | 417 | widthOfWhitespaceAfterWord = startXOfPreviousWord - widthOfWhitespaceAfterWord; 418 | 419 | if (maxX < widthOfWhitespaceAfterWord) { 420 | maxX = widthOfWhitespaceAfterWord; 421 | } 422 | 423 | previousCharId = NaN; 424 | currentX -= startXOfPreviousWord; 425 | currentY += lineHeight; 426 | startXOfPreviousWord = 0; 427 | widthOfWhitespaceAfterWord = 0; 428 | wordLength = 0; 429 | isWordComplete = false; 430 | wordCountForLine = 0; 431 | this._numLines++; 432 | } 433 | } 434 | 435 | if (this._wordWrap || isAligned) { 436 | let charLocation = null; 437 | 438 | if (charLocationPoolIndex > -1) { 439 | charLocation = charLocationPool[charLocationPoolIndex]; 440 | charLocationPoolIndex--; 441 | } else { 442 | charLocation = new CharLocation(); 443 | } 444 | 445 | charLocation.char = charData; 446 | charLocation.x = currentX + charData.xOffset * scale; 447 | charLocation.y = currentY + charData.yOffset * scale; 448 | charLocation.scale = scale; 449 | CHARACTER_BUFFER[CHARACTER_BUFFER.length] = charLocation; 450 | wordLength++; 451 | } else { 452 | this.addCharacterToBatch(charData, 453 | currentX + charData.xOffset * scale, 454 | currentY + charData.yOffset * scale, 455 | scale 456 | ); 457 | } 458 | 459 | currentX += xAdvance + customLetterSpacing; 460 | previousCharId = charId; 461 | } 462 | 463 | currentX = currentX - customLetterSpacing; 464 | 465 | if (currentX < 0) { 466 | currentX = 0; 467 | } 468 | 469 | if (this._wordWrap || isAligned) { 470 | this.alignBuffer(maxLineWidth, currentX, 0); 471 | this.addBufferToBatch(0); 472 | } 473 | 474 | // if the text ends in extra whitespace, the currentX value will be 475 | // larger than the max line width. we'll remove that and add extra 476 | // lines. 477 | 478 | if (this._wordWrap) { 479 | while (currentX > maxLineWidth) { 480 | currentX -= maxLineWidth; 481 | currentY += lineHeight; 482 | if (maxLineWidth === 0) { 483 | //we don't want to get stuck in an infinite loop! 484 | break; 485 | } 486 | } 487 | } 488 | 489 | if (maxX < currentX) { 490 | maxX = currentX; 491 | } 492 | 493 | if (isAligned && !hasExplicitWidth) { 494 | const align = this._align; 495 | if (align === Align.CENTER) { 496 | this._batchX = (maxX - maxLineWidth) / 2; 497 | } 498 | else if (align === Align.RIGHT) { 499 | this._batchX = maxX - maxLineWidth; 500 | } 501 | } else { 502 | this._batchX = 0; 503 | } 504 | 505 | this._verticalAlignOffsetY = this.getVerticalAlignOffsetY(); 506 | 507 | for (let i = 0; i < this._activeCharacterCount; i++) { 508 | let charView = this._activeCharacters[i]; 509 | charView.position.x += this._batchX; 510 | charView.position.y += this._verticalAlignOffsetY; 511 | } 512 | 513 | result.width = maxX; 514 | result.height = currentY + lineHeight - this._leading; 515 | return result; 516 | } 517 | 518 | trimBuffer (skipCount) { 519 | const charCount = CHARACTER_BUFFER.length - skipCount; 520 | let countToRemove = 0; 521 | let i; 522 | 523 | for (i = charCount - 1; i >= 0; i--) { 524 | const charLocation = CHARACTER_BUFFER[i]; 525 | const charData = charLocation.char; 526 | const charId = charData.charId; 527 | 528 | if (charId === CHARACTER_ID_SPACE || charId === CHARACTER_ID_TAB) { 529 | countToRemove++; 530 | } else { 531 | break; 532 | } 533 | } 534 | 535 | if (countToRemove > 0) { 536 | CHARACTER_BUFFER.splice(i + 1, countToRemove); 537 | } 538 | 539 | } 540 | 541 | alignBuffer(maxLineWidth, currentLineWidth, skipCount) { 542 | const align = this._align; 543 | if (align === Align.CENTER) { 544 | this.moveBufferedCharacters(round((maxLineWidth - currentLineWidth) / 2), 0, skipCount); 545 | } else if (align === Align.RIGHT) { 546 | this.moveBufferedCharacters(maxLineWidth - currentLineWidth, 0, skipCount); 547 | } 548 | } 549 | 550 | addBufferToBatch (skipCount) { 551 | const charCount = CHARACTER_BUFFER.length - skipCount; 552 | for (let i = 0; i < charCount; i++) { 553 | const charLocation = CHARACTER_BUFFER.shift(); 554 | this.addCharacterToBatch(charLocation.char, charLocation.x, charLocation.y, charLocation.scale); 555 | charLocation.char = null; 556 | charLocationPool[++charLocationPoolIndex] = charLocation; 557 | } 558 | } 559 | 560 | moveBufferedCharacters(xOffset, yOffset, skipCount) { 561 | const charCount = CHARACTER_BUFFER.length - skipCount; 562 | for (let i = 0; i < charCount; i++) { 563 | const charLocation = CHARACTER_BUFFER[i]; 564 | charLocation.x += xOffset; 565 | charLocation.y += yOffset; 566 | } 567 | } 568 | 569 | addCharacterToBatch(charData, x, y, scale) { 570 | if (!charData.texture) { 571 | return; 572 | } 573 | 574 | let charView = null; 575 | 576 | if (characterViewPoolIndex > -1) { 577 | charView = characterViewPool[characterViewPoolIndex]; 578 | characterViewPoolIndex--; 579 | } else { 580 | charView = new Sprite(); 581 | } 582 | 583 | charView.texture = charData.texture; 584 | charView.position.set(x, y); 585 | charView.width = charData.texture.width * scale; 586 | charView.height = charData.texture.height * scale; 587 | charView.tint = this._color; 588 | 589 | this._activeCharacters[this._activeCharacterCount] = charView; 590 | this._activeCharacterCount++; 591 | 592 | this.addChild(charView); 593 | } 594 | 595 | getTruncatedText (width) { 596 | if (!this._text) { 597 | // this shouldn't be called if _text is null, but just in case... 598 | return ''; 599 | } 600 | 601 | // if the width is infinity or the string is multiline, don't allow truncation 602 | if (width === Number.POSITIVE_INFINITY || this._wordWrap || this._text.indexOf(String.fromCharCode(CHARACTER_ID_LINE_FEED)) >= 0 || this._text.indexOf(String.fromCharCode(CHARACTER_ID_CARRIAGE_RETURN)) >= 0) { 603 | return this._text; 604 | } 605 | 606 | const font = this._fontData; 607 | const customSize = this._size; 608 | const customLetterSpacing = this._letterSpacing; 609 | const isKerningEnabled = this._isKerningEnabled; 610 | 611 | let scale = customSize / font.size; 612 | 613 | if (isNaN(scale)) { 614 | scale = 1; 615 | } 616 | 617 | let currentX = 0; 618 | let previousCharId = NaN; 619 | let charCount = this._text.length; 620 | let truncationIndex = -1; 621 | let currentKerning = 0; 622 | 623 | for (let i = 0; i < charCount; i++) { 624 | const charId = this._text.charCodeAt(i); 625 | const charData = font.chars[charId]; 626 | 627 | if (!charData) { 628 | continue; 629 | } 630 | 631 | currentKerning = 0; 632 | 633 | if (isKerningEnabled && !isNaN(previousCharId)) { 634 | currentKerning = (charData.kerning[previousCharId] || 0) * scale; 635 | } 636 | 637 | currentX += currentKerning + charData.xAdvance * scale; 638 | 639 | if (currentX > width) { 640 | //floating point errors can cause unnecessary truncation, 641 | //so we're going to be a little bit fuzzy on the greater 642 | //than check. such tiny numbers shouldn't break anything. 643 | const difference = abs(currentX - width); 644 | if (difference > FUZZY_MAX_WIDTH_PADDING) { 645 | truncationIndex = i; 646 | break; 647 | } 648 | } 649 | 650 | currentX += customLetterSpacing; 651 | previousCharId = charId; 652 | } 653 | 654 | if (truncationIndex >= 0) { 655 | //first measure the size of the truncation text 656 | charCount = this._truncationText.length; 657 | for (let i = 0; i < charCount; i++) { 658 | const charId = this._truncationText.charCodeAt(i); 659 | const charData = font.chars[charId]; 660 | 661 | if (!charData) { 662 | continue; 663 | } 664 | 665 | currentKerning = 0; 666 | 667 | if (isKerningEnabled && !isNaN(previousCharId)) { 668 | currentKerning = (charData.kerning[previousCharId] || 0) * scale; 669 | } 670 | 671 | currentX += currentKerning + charData.xAdvance * scale + customLetterSpacing; 672 | previousCharId = charId; 673 | } 674 | 675 | currentX -= customLetterSpacing; 676 | 677 | //then work our way backwards until we fit into the width 678 | for (let i = truncationIndex; i >= 0; i--) { 679 | const charId = this._text.charCodeAt(i); 680 | previousCharId = i > 0 ? this._text.charCodeAt(i - 1) : NaN; 681 | const charData = font.chars[charId]; 682 | if (!charData) { 683 | continue; 684 | } 685 | 686 | currentKerning = 0; 687 | 688 | if (isKerningEnabled && !isNaN(previousCharId)) { 689 | currentKerning = (charData.kerning[previousCharId] || 0) * scale; 690 | } 691 | 692 | currentX -= (currentKerning + charData.xAdvance * scale + customLetterSpacing); 693 | 694 | if (currentX <= width) { 695 | return this._text.substr(0, i) + this._truncationText; 696 | } 697 | } 698 | 699 | return this._truncationText; 700 | } 701 | 702 | return this._text; 703 | } 704 | 705 | getVerticalAlignOffsetY () { 706 | const font = this._fontData; 707 | const customSize = this._size; 708 | let scale = customSize / font.size; 709 | 710 | if (isNaN(scale)) { 711 | scale = 1; 712 | } 713 | 714 | const lineHeight = font.lineHeight * scale + this._leading; 715 | const textHeight = this._numLines * lineHeight; 716 | 717 | if (textHeight > this._height) { 718 | return 0; 719 | } 720 | 721 | if (this._verticalAlign === Align.BOTTOM) { 722 | return (this._height - textHeight); 723 | } else if (this._verticalAlign === Align.CENTER) { 724 | return (this._height - textHeight) / 2; 725 | } 726 | return 0; 727 | } 728 | 729 | get align () { 730 | return this._align; 731 | } 732 | 733 | set align (value) { 734 | if (this._align === value) { 735 | return; 736 | } 737 | 738 | this._align = value; 739 | this.invalidate(); 740 | } 741 | 742 | get maxWidth () { 743 | return this._maxWidth; 744 | } 745 | 746 | set maxWidth (value) { 747 | if (value < 0) { value = 0; } 748 | if (this._explicitMaxWidth === value) { return; } 749 | 750 | const needsInvalidate = value > this._explicitMaxWidth && this._lastLayoutIsTruncated; 751 | 752 | if (isNaN(value)) { 753 | throw new Error('maxWidth cannot be NaN'); 754 | } 755 | 756 | const oldValue = this._explicitMaxWidth; 757 | 758 | this._explicitMaxWidth = value; 759 | 760 | if (needsInvalidate || (isNaN(this._width) && (this.actualWidth > value || this.actualWidth === oldValue))) { 761 | //only invalidate if this change might affect the width 762 | this.invalidate(); 763 | } 764 | } 765 | 766 | get numLines () { 767 | return this._numLines; 768 | } 769 | 770 | get truncateToFit () { 771 | return this._truncateToFit; 772 | } 773 | 774 | set truncateToFit (value) { 775 | if (this._truncateToFit === value) { return; } 776 | this._truncateToFit = value; 777 | this.invalidate(); 778 | } 779 | 780 | get truncationText () { 781 | return this._truncationText; 782 | } 783 | 784 | set truncationText (value) { 785 | if (this._truncationText === value) { return; } 786 | this._truncationText = value; 787 | this.invalidate(); 788 | } 789 | 790 | get font () { 791 | return this._font; 792 | } 793 | 794 | set font (value) { 795 | if (this._font === value) { 796 | return; 797 | } 798 | 799 | this._font = value; 800 | this.invalidate(); 801 | } 802 | 803 | get leading () { 804 | return this._leading; 805 | } 806 | 807 | set leading (value) { 808 | if (value === this._leading) { return; } 809 | this._leading = value; 810 | this.invalidate(); 811 | } 812 | 813 | get size () { 814 | return this._size; 815 | } 816 | 817 | set size (value) { 818 | if (value === this._size) { return; } 819 | this._size = value; 820 | this._textDirty = true; 821 | this.invalidate(); 822 | } 823 | 824 | get wordWrap () { 825 | return this._wordWrap; 826 | } 827 | 828 | set wordWrap (value) { 829 | if (value === this._wordWrap) { return; } 830 | this._wordWrap = value; 831 | this.invalidate(); 832 | } 833 | 834 | get color () { 835 | return this._color; 836 | } 837 | 838 | set color (value) { 839 | if (value === this._color) { return; } 840 | this._color = value; 841 | this._textDirty = true; 842 | this.invalidate(); 843 | } 844 | 845 | get baseline () { 846 | const font = this._fontData; 847 | const formatSize = this._size; 848 | const baseline = font.baseline; 849 | 850 | let fontSizeScale = formatSize / font.size; 851 | 852 | if (isNaN(fontSizeScale)) { 853 | fontSizeScale = 1; 854 | } 855 | 856 | if (isNaN(baseline)) { 857 | return font.lineHeight * fontSizeScale; 858 | } 859 | 860 | return baseline * fontSizeScale; 861 | } 862 | 863 | get text () { 864 | return this._text; 865 | } 866 | 867 | set text (value) { 868 | value = `${ value }`; 869 | 870 | if (this._text === value) { 871 | return; 872 | } 873 | 874 | this._textDirty = true; 875 | this._text = value; 876 | this.invalidate(); 877 | } 878 | 879 | get width () { 880 | return this._width; 881 | } 882 | 883 | set width (value) { 884 | if (this._width === value) { 885 | return; 886 | } 887 | 888 | this._width = value; 889 | this.invalidate(); 890 | } 891 | 892 | get height () { 893 | return this._height; 894 | } 895 | 896 | set height (value) { 897 | if (this._height === value) { 898 | return; 899 | } 900 | 901 | this._height = value; 902 | this.invalidate(); 903 | } 904 | 905 | } 906 | 907 | class CharLocation { 908 | char = ''; 909 | scale = 1; 910 | x = 0; 911 | y = 0; 912 | } 913 | 914 | export { Align }; 915 | -------------------------------------------------------------------------------- /example/public/arial_black.fnt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | --------------------------------------------------------------------------------