├── .gitignore ├── demo.gif ├── .npmignore ├── webpack.config.js ├── tests ├── karma.conf.js ├── index.test.js ├── __init.test.js └── helpers.js ├── LICENSE ├── package.json ├── README.md ├── examples └── basic │ └── index.html ├── index.html ├── dist ├── aframe-textarea-component.min.js └── aframe-textarea-component.js └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | tests/tmp/ 3 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brianpeiris/aframe-textarea-component/HEAD/demo.gif -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .sw[ponm] 2 | examples/build.js 3 | examples/node_modules/ 4 | gh-pages 5 | node_modules/ 6 | npm-debug.log 7 | .component 8 | package-lock.json 9 | yarn.lock 10 | tests/ 11 | demo.gif 12 | *.html 13 | webpack.config.js 14 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = [ 4 | { 5 | name: 'development', 6 | mode: 'development', 7 | entry: './index.js', 8 | output: { 9 | path: path.join(__dirname, 'dist'), 10 | filename: 'aframe-textarea-component.js' 11 | } 12 | }, 13 | { 14 | name: 'production', 15 | mode: 'production', 16 | entry: './index.js', 17 | output: { 18 | path: path.join(__dirname, 'dist'), 19 | filename: 'aframe-textarea-component.min.js' 20 | } 21 | } 22 | ]; 23 | -------------------------------------------------------------------------------- /tests/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration. 2 | module.exports = function (config) { 3 | config.set({ 4 | basePath: '../', 5 | browserify: { 6 | debug: true, 7 | paths: ['./'], 8 | ignoreMissing: true 9 | }, 10 | browsers: ['FirefoxSnap', 'Chrome'], 11 | customLaunchers: { 12 | FirefoxSnap: { 13 | base: 'Firefox', 14 | profile: require('path').join(__dirname, 'tmp') 15 | } 16 | }, 17 | client: { 18 | captureConsole: true, 19 | mocha: { ui: 'tdd' } 20 | }, 21 | envPreprocessor: ['TEST_ENV'], 22 | files: [ 23 | // Define test files. 24 | { pattern: 'tests/**/*.test.js' } 25 | ], 26 | frameworks: ['mocha', 'sinon-chai', 'chai-shallow-deep-equal', 'browserify'], 27 | preprocessors: { 'tests/**/*.js': ['browserify', 'env'] }, 28 | reporters: ['mocha'] 29 | }); 30 | }; 31 | -------------------------------------------------------------------------------- /tests/index.test.js: -------------------------------------------------------------------------------- 1 | /* global assert, setup, suite, test */ 2 | require('aframe'); 3 | require('../index.js'); 4 | const entityFactory = require('./helpers').entityFactory; 5 | 6 | suite('textarea component', function () { 7 | let component; 8 | let el; 9 | 10 | setup(function (done) { 11 | el = entityFactory(); 12 | el.addEventListener('componentinitialized', function (evt) { 13 | if (evt.detail.name !== 'textarea') { return; } 14 | component = el.components.textarea; 15 | done(); 16 | }); 17 | el.setAttribute('textarea', { text: 'hello world' }); 18 | }); 19 | 20 | suite('text', function () { 21 | test('is set correctly', function () { 22 | assert.equal(component.textarea.value, 'hello world'); 23 | assert.equal(component.textarea.selectionStart, 0); 24 | assert.equal(component.textarea.selectionEnd, 0); 25 | }); 26 | 27 | test('can be accessed', function () { 28 | assert.equal(component.getText(), 'hello world'); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 brian@peiris.io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /tests/__init.test.js: -------------------------------------------------------------------------------- 1 | /* global sinon, setup, teardown */ 2 | 3 | /** 4 | * __init.test.js is run before every test case. 5 | */ 6 | window.debug = true; 7 | const AScene = require('aframe').AScene; 8 | 9 | navigator.getVRDisplays = function () { 10 | const resolvePromise = Promise.resolve(); 11 | const mockVRDisplay = { 12 | requestPresent: resolvePromise, 13 | exitPresent: resolvePromise, 14 | getPose: function () { return { orientation: null, position: null }; }, 15 | requestAnimationFrame: function () { return 1; } 16 | }; 17 | return Promise.resolve([mockVRDisplay]); 18 | }; 19 | 20 | setup(function () { 21 | const sandbox = this.sinon = sinon.createSandbox(); 22 | // Stubs to not create a WebGL context since Travis CI runs headless. 23 | this.sinon.stub(AScene.prototype, 'render'); 24 | this.sinon.stub(AScene.prototype, 'resize'); 25 | this.sinon.stub(AScene.prototype, 'setupRenderer').callsFake(function () { 26 | this.renderer = { shadowMap: {}, getContext: sandbox.stub(), xr: { dispose: sandbox.stub() } }; 27 | }); 28 | }); 29 | 30 | teardown(function () { 31 | // Clean up any attached elements. 32 | const attachedEls = ['canvas', 'a-assets', 'a-scene']; 33 | const els = document.querySelectorAll(attachedEls.join(',')); 34 | for (let i = 0; i < els.length; i++) { 35 | els[i].parentNode.removeChild(els[i]); 36 | } 37 | this.sinon.restore(); 38 | }); 39 | -------------------------------------------------------------------------------- /tests/helpers.js: -------------------------------------------------------------------------------- 1 | /* global suite */ 2 | 3 | /** 4 | * Helper method to create a scene, create an entity, add entity to scene, 5 | * add scene to document. 6 | * 7 | * @returns {object} An `` element. 8 | */ 9 | module.exports.entityFactory = function (opts) { 10 | const scene = document.createElement('a-scene'); 11 | const assets = document.createElement('a-assets'); 12 | const entity = document.createElement('a-entity'); 13 | scene.appendChild(assets); 14 | scene.appendChild(entity); 15 | 16 | opts = opts || {}; 17 | 18 | if (opts.assets) { 19 | opts.assets.forEach(function (asset) { 20 | assets.appendChild(asset); 21 | }); 22 | } 23 | 24 | document.body.appendChild(scene); 25 | return entity; 26 | }; 27 | 28 | /** 29 | * Creates and attaches a mixin element (and an `` element if necessary). 30 | * 31 | * @param {string} id - ID of mixin. 32 | * @param {object} obj - Map of component names to attribute values. 33 | * @param {Element} scene - Indicate which scene to apply mixin to if necessary. 34 | * @returns {object} An attached `` element. 35 | */ 36 | module.exports.mixinFactory = function (id, obj, scene) { 37 | const mixinEl = document.createElement('a-mixin'); 38 | mixinEl.setAttribute('id', id); 39 | Object.keys(obj).forEach(function (componentName) { 40 | mixinEl.setAttribute(componentName, obj[componentName]); 41 | }); 42 | 43 | const assetsEl = scene ? scene.querySelector('a-assets') : document.querySelector('a-assets'); 44 | assetsEl.appendChild(mixinEl); 45 | 46 | return mixinEl; 47 | }; 48 | 49 | /** 50 | * Test that is only run locally and is skipped on CI. 51 | */ 52 | module.exports.getSkipCISuite = function () { 53 | if (window.__env__.TEST_ENV === 'ci') { 54 | return suite.skip; 55 | } else { 56 | return suite; 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aframe-textarea-component", 3 | "version": "0.3.0", 4 | "description": "A Textarea component for A-Frame.", 5 | "main": "index.js", 6 | "unpkg": "dist/aframe-textarea-component.min.js", 7 | "scripts": { 8 | "dev": "budo index.js:dist/aframe-textarea-component.min.js --port 7000 --live --open", 9 | "dist": "webpack --config-name development && webpack --config-name production", 10 | "lint": "semistandard -v | snazzy", 11 | "start": "npm run dev", 12 | "test": "karma start ./tests/karma.conf.js", 13 | "test:firefox": "karma start ./tests/karma.conf.js --browsers FirefoxSnap", 14 | "test:chrome": "karma start ./tests/karma.conf.js --browsers Chrome" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/brianpeiris/aframe-textarea-component.git" 19 | }, 20 | "keywords": [ 21 | "aframe", 22 | "aframe-component", 23 | "aframe-vr", 24 | "vr", 25 | "mozvr", 26 | "webvr", 27 | "textarea" 28 | ], 29 | "author": "brian@peiris.io", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/brianpeiris/aframe-textarea-component/issues" 33 | }, 34 | "homepage": "https://github.com/brianpeiris/aframe-textarea-component#readme", 35 | "devDependencies": { 36 | "aframe": "^1.3.0", 37 | "browserify": "^17.0.0", 38 | "browserify-css": "^0.15.0", 39 | "budo": "^11.8.4", 40 | "chai": "^4.3.7", 41 | "chai-shallow-deep-equal": "^1.3.0", 42 | "envify": "^4.1.0", 43 | "karma": "^6.4.1", 44 | "karma-browserify": "^8.1.0", 45 | "karma-chai-shallow-deep-equal": "0.0.4", 46 | "karma-chrome-launcher": "2.0.0", 47 | "karma-env-preprocessor": "^0.1.1", 48 | "karma-firefox-launcher": "^2.1.2", 49 | "karma-mocha": "^0.2.1", 50 | "karma-mocha-reporter": "^1.1.3", 51 | "karma-sinon-chai": "^2.0.2", 52 | "mocha": "^10.1.0", 53 | "semistandard": "^16.0.1", 54 | "sinon": "^14.0.2", 55 | "sinon-chai": "^3.7.0", 56 | "snazzy": "^9.0.0", 57 | "webpack": "^5.74.0", 58 | "webpack-cli": "^4.10.0" 59 | }, 60 | "semistandard": { 61 | "globals": [ 62 | "AFRAME", 63 | "THREE" 64 | ], 65 | "ignore": [ 66 | "examples/build.js", 67 | "dist/**" 68 | ] 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## aframe-textarea-component 2 | 3 | [![Version](http://img.shields.io/npm/v/aframe-textarea-component.svg?style=flat-square)](https://npmjs.org/package/aframe-textarea-component) 4 | [![License](http://img.shields.io/npm/l/aframe-textarea-component.svg?style=flat-square)](https://npmjs.org/package/aframe-textarea-component) 5 | 6 | A Textarea component for [A-Frame](https://aframe.io). 7 | 8 | ### Examples 9 | 10 | - [Basic Example](https://brianpeiris.github.io/aframe-textarea-component/examples/basic/) 11 | 12 | ![Demo gif](demo.gif) 13 | 14 | ### Known issues 15 | 16 | - Does not support text word-wrap. 17 | - Only supports monospace fonts. 18 | 19 | ### API 20 | 21 | | Property | Description | Default Value | 22 | | -------- | ----------- | ------------- | 23 | | cols | number of columns in the textarea | 40 | 24 | | rows | number of rows in the textarea | 20 | 25 | | color | color of the text | black | 26 | | disabled | whether the control can receive keyboard inputs | false | 27 | | selectionColor | color of selected text | grey | 28 | | backgroundColor | color of the background | white | 29 | | disabledBackgroundColor | color of the background when disabled | lightgrey | 30 | | text | default text in the textarea | '' | 31 | 32 | | Method | Description | 33 | | -------- | ----------- | 34 | | getText() | Get the current text in the textarea | 35 | | focus() | Focus the textarea | 36 | | blur() | Blur the textarea | 37 | 38 | ### Installation 39 | 40 | #### Browser 41 | 42 | Install and use by directly including the [browser files](dist): 43 | 44 | ```html 45 | 46 | My A-Frame Scene 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | ``` 58 | 59 | #### npm 60 | 61 | Install via npm: 62 | 63 | ```bash 64 | npm install aframe-textarea-component 65 | ``` 66 | 67 | Then require and use. 68 | 69 | ```js 70 | require('aframe'); 71 | require('aframe-textarea-component'); 72 | ``` 73 | -------------------------------------------------------------------------------- /examples/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | A-Frame Textarea Component - Basic 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 48 | 49 | 50 |

A-Frame Textarea Component

51 | 52 |
    53 |
  • 54 | 55 |

    Basic

    56 |

    This is a basic example.

    57 |
  • 58 |
59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /dist/aframe-textarea-component.min.js: -------------------------------------------------------------------------------- 1 | (()=>{if("undefined"==typeof AFRAME)throw new Error("Component attempted to register before AFRAME was available.");AFRAME.registerComponent("textarea",{schema:{cols:{type:"int",default:40},rows:{type:"int",default:20},color:{type:"color",default:"black"},backgroundColor:{type:"color",default:"white"},selectionColor:{type:"color",default:"grey"},disabledBackgroundColor:{type:"color",default:"lightgrey"},disabled:{type:"boolean",default:!1},text:{type:"string",default:""}},init:function(){this.text=null,this.lines=[],this.lastBlink=0,this.showCursorTimeout=0,this.blinkEnabled=!this.data.disabled,this.charWidth=this.charHeight=null,this.selectionStart=this.selectionEnd=0,this.endIndexInfo=this.startIndexInfo=null,this.origin={x:0,y:0},this.textarea=null,this.background=document.createElement("a-plane"),this.background.setAttribute("color",this.data.disabled?this.data.disabledBackgroundColor:this.data.backgroundColor),this.el.appendChild(this.background),this.el.setObject3D("background",this.background.object3D),this.textAnchor=document.createElement("a-entity"),this.el.appendChild(this.textAnchor),this.textAnchor.setAttribute("text",{mode:"pre",baseline:"top",anchor:"center",font:"dejavu",wrapCount:this.data.cols,height:this.data.rows,color:this.data.color}),this._initTextarea(),this._initCursor(),this.el.addEventListener("textfontset",this._updateCharMetrics.bind(this)),this.el.addEventListener("char-metrics-changed",this._updateIndexInfo.bind(this)),this.el.addEventListener("char-metrics-changed",this._updateCursorGeometry.bind(this)),this.el.addEventListener("text-changed",this._updateLines.bind(this)),this.el.addEventListener("text-changed",this._updateDisplayText.bind(this)),this.el.addEventListener("text-changed",this._setShowCursorTimeout.bind(this)),this.el.addEventListener("selection-changed",this._updateIndexInfo.bind(this)),this.el.addEventListener("selection-changed",this._updateCursorStyle.bind(this)),this.el.addEventListener("selection-changed",this._updateCursorGeometry.bind(this)),this.el.addEventListener("selection-changed",this._updateHorizontalOrigin.bind(this)),this.el.addEventListener("selection-changed",this._setShowCursorTimeout.bind(this)),this.el.addEventListener("lines-changed",this._updateIndexInfo.bind(this)),this.el.addEventListener("index-info-changed",this._updateOrigin.bind(this)),this.el.addEventListener("index-info-changed",this._updateCursorGeometry.bind(this)),this.el.addEventListener("index-info-changed",this._updateHorizontalOrigin.bind(this)),this.el.addEventListener("origin-changed",this._updateCursorGeometry.bind(this)),this.el.addEventListener("origin-changed",this._updateDisplayText.bind(this)),this.el.addEventListener("click",this.focus.bind(this))},update:function(t){this.data.text!==t.text&&this._updateTextarea(),this.data.backgroundColor===t.backgroundColor&&this.data.disabledBackgroundColor===t.disabledBackgroundColor||this.background.setAttribute("color",this.data.disabled?this.data.disabledBackgroundColor:this.data.backgroundColor),this.data.disabled!==t.disabled&&(this.blinkEnabled=!this.data.disabled,this.textarea.disabled=this.data.disabled,this.cursorMesh.visible=!this.data.disabled,this.background.setAttribute("color",this.data.disabled?this.data.disabledBackgroundColor:this.data.backgroundColor))},focus:function(){this.textarea.focus()},blur:function(){this.textarea.blur()},getText:function(){return this.textarea.value},_initTextarea:function(){this.textarea=document.createElement("textarea"),document.body.appendChild(this.textarea),this._updateTextarea()},_updateTextarea:function(){this.textarea.style.whiteSpace="pre",this.textarea.style.overflow="hidden",this.textarea.style.opacity="0",this.textarea.cols=this.data.cols,this.textarea.rows=this.data.rows,this.textarea.value=this.data.text,this.textarea.selectionStart=0,this.textarea.selectionEnd=0,this._updateIndexInfo()},_initCursor:function(){this.cursor=document.createElement("a-entity"),this.cursorGeo=new THREE.PlaneGeometry(1,1),this.cursorMat=new THREE.MeshBasicMaterial({color:"black",transparent:!0,opacity:.5}),this.cursorMesh=new THREE.Mesh(this.cursorGeo,this.cursorMat),this.cursor.setObject3D("mesh",this.cursorMesh),this.el.appendChild(this.cursor)},_emit:function(t,e){this.el.emit(t,e)},_updateCharMetrics:function(t){const e=this.textAnchor.components.text.geometry.layout,i=t.detail.fontObj.widthFactor;this.charWidth=i*this.textAnchor.object3DMap.text.scale.x,this.charHeight=this.charWidth*e.lineHeight/i,this.textAnchor.setAttribute("position",{x:0,y:this.charHeight*this.data.rows/2,z:0}),this.background.setAttribute("scale",{x:1.05,y:this.charHeight*this.data.rows*1.05,z:1}),this.background.setAttribute("position",{x:0,y:0,z:0}),this._emit("char-metrics-changed")},_checkAndUpdateSelection:function(){if(this.selectionStart===this.textarea.selectionStart&&this.selectionEnd===this.textarea.selectionEnd)return;const t=this.selectionStart,e=this.selectionEnd;this.selectionStart=this.textarea.selectionStart,this.selectionEnd=this.textarea.selectionEnd,this._emit("selection-changed",{start:{old:t,new:this.selectionStart,changed:this.selectionStart!==t},end:{old:e,new:this.selectionEnd,changed:this.selectionEnd!==e}})},_setShowCursorTimeout:function(){this.showCursorTimeout=500},tick:function(t,e){this._updateCursorVisibility(e),this._checkAndUpdateSelection(),this._checkAndUpdateText()},_updateCursorVisibility:function(t){document.activeElement===this.textarea?this.showCursorTimeout>0?(this.showCursorTimeout-=t,this.cursorMesh.visible=!0):this.blinkEnabled?Date.now()-this.lastBlink>500&&(this.cursorMesh.visible=!this.cursorMesh.visible,this.lastBlink=Date.now()):this.selectionStart!==this.selectionEnd&&(this.cursorMesh.visible=!0):this.cursorMesh.visible=!1},_getIndexInfo:function(t,e){const i=Math.max(0,t),s=this.lines[i];return{line:s,x:(e-s.start)*this.charWidth,y:-this.charHeight*i+-this.charHeight/2}},_updateIndexInfo:function(){if(!this.lines.length)return;const t=this.startIndexInfo&&this.startIndexInfo.line.index,e=this.endIndexInfo&&this.endIndexInfo.line.index;let i;this.startIndexInfo=null,this.endIndexInfo=null;let s=!1,n=!1;for(i=0;i<=this.lines.length;i++){const h=this.lines[i-1],a=i===this.lines.length?h.start+h.length+1:this.lines[i].start;if(a>this.selectionStart&&!this.startIndexInfo&&(this.startIndexInfo=this._getIndexInfo(i-1,this.selectionStart),this.startIndexInfo.line.index!==t&&(s=!0)),a>this.selectionEnd){this.endIndexInfo=this._getIndexInfo(i-1,this.selectionEnd),this.endIndexInfo.line.index!==e&&(n=!0);break}}(s||n)&&this._emit("index-info-changed",{start:{changed:s},end:{changed:n}})},_updateOrigin:function(t){let e=!1;if(t.detail.end.changed){const t=this.origin.y+this.data.rows-1;this.endIndexInfo.line.index>t?(this.origin.y=this.endIndexInfo.line.index+1-this.data.rows,e=!0):this.endIndexInfo.line.indexthis.origin.x+this.data.cols?(this.origin.x=t-this.data.cols,e=!0):tthis.origin.x+this.data.cols?(this.origin.x=i-this.data.cols,e=!0):i { // webpackBootstrap 10 | /******/ var __webpack_modules__ = ({ 11 | 12 | /***/ "./index.js": 13 | /*!******************!*\ 14 | !*** ./index.js ***! 15 | \******************/ 16 | /***/ (() => { 17 | 18 | eval("/* global AFRAME */\n\nif (typeof AFRAME === 'undefined') {\n throw new Error('Component attempted to register before AFRAME was available.');\n}\n\n/**\n * Textarea component for A-Frame.\n */\nAFRAME.registerComponent('textarea', {\n schema: {\n cols: { type: 'int', default: 40 },\n rows: { type: 'int', default: 20 },\n color: { type: 'color', default: 'black' },\n backgroundColor: { type: 'color', default: 'white' },\n selectionColor: { type: 'color', default: 'grey' },\n disabledBackgroundColor: { type: 'color', default: 'lightgrey' },\n disabled: { type: 'boolean', default: false },\n text: { type: 'string', default: '' }\n },\n init: function () {\n this.text = null;\n this.lines = [];\n this.lastBlink = 0;\n this.showCursorTimeout = 0;\n this.blinkEnabled = !this.data.disabled;\n this.charWidth = this.charHeight = null;\n this.selectionStart = this.selectionEnd = 0;\n this.endIndexInfo = this.startIndexInfo = null;\n this.origin = { x: 0, y: 0 };\n this.textarea = null;\n\n this.background = document.createElement('a-plane');\n this.background.setAttribute('color', this.data.disabled ? this.data.disabledBackgroundColor : this.data.backgroundColor);\n this.el.appendChild(this.background);\n this.el.setObject3D('background', this.background.object3D);\n\n this.textAnchor = document.createElement('a-entity');\n this.el.appendChild(this.textAnchor);\n this.textAnchor.setAttribute('text', {\n mode: 'pre',\n baseline: 'top',\n anchor: 'center',\n font: 'dejavu',\n wrapCount: this.data.cols,\n height: this.data.rows,\n color: this.data.color\n });\n\n this._initTextarea();\n\n this._initCursor();\n\n this.el.addEventListener('textfontset', this._updateCharMetrics.bind(this));\n this.el.addEventListener('char-metrics-changed', this._updateIndexInfo.bind(this));\n this.el.addEventListener('char-metrics-changed', this._updateCursorGeometry.bind(this));\n this.el.addEventListener('text-changed', this._updateLines.bind(this));\n this.el.addEventListener('text-changed', this._updateDisplayText.bind(this));\n this.el.addEventListener('text-changed', this._setShowCursorTimeout.bind(this));\n this.el.addEventListener('selection-changed', this._updateIndexInfo.bind(this));\n this.el.addEventListener('selection-changed', this._updateCursorStyle.bind(this));\n this.el.addEventListener('selection-changed', this._updateCursorGeometry.bind(this));\n this.el.addEventListener('selection-changed', this._updateHorizontalOrigin.bind(this));\n this.el.addEventListener('selection-changed', this._setShowCursorTimeout.bind(this));\n this.el.addEventListener('lines-changed', this._updateIndexInfo.bind(this));\n this.el.addEventListener('index-info-changed', this._updateOrigin.bind(this));\n this.el.addEventListener('index-info-changed', this._updateCursorGeometry.bind(this));\n this.el.addEventListener('index-info-changed', this._updateHorizontalOrigin.bind(this));\n this.el.addEventListener('origin-changed', this._updateCursorGeometry.bind(this));\n this.el.addEventListener('origin-changed', this._updateDisplayText.bind(this));\n this.el.addEventListener('click', this.focus.bind(this));\n },\n update: function (oldData) {\n if (this.data.text !== oldData.text) {\n this._updateTextarea();\n }\n\n if (this.data.backgroundColor !== oldData.backgroundColor || this.data.disabledBackgroundColor !== oldData.disabledBackgroundColor) {\n this.background.setAttribute('color', this.data.disabled ? this.data.disabledBackgroundColor : this.data.backgroundColor);\n }\n\n if (this.data.disabled !== oldData.disabled) {\n this.blinkEnabled = !this.data.disabled;\n this.textarea.disabled = this.data.disabled;\n this.cursorMesh.visible = !this.data.disabled;\n this.background.setAttribute('color', this.data.disabled ? this.data.disabledBackgroundColor : this.data.backgroundColor);\n }\n },\n focus: function () {\n this.textarea.focus();\n },\n blur: function () {\n this.textarea.blur();\n },\n getText: function () {\n return this.textarea.value;\n },\n _initTextarea: function () {\n this.textarea = document.createElement('textarea');\n document.body.appendChild(this.textarea);\n this._updateTextarea();\n },\n _updateTextarea: function () {\n this.textarea.style.whiteSpace = 'pre';\n this.textarea.style.overflow = 'hidden';\n this.textarea.style.opacity = '0';\n\n this.textarea.cols = this.data.cols;\n this.textarea.rows = this.data.rows;\n this.textarea.value = this.data.text;\n this.textarea.selectionStart = 0;\n this.textarea.selectionEnd = 0;\n\n this._updateIndexInfo();\n },\n _initCursor: function () {\n this.cursor = document.createElement('a-entity');\n this.cursorGeo = new THREE.PlaneGeometry(1, 1);\n this.cursorMat = new THREE.MeshBasicMaterial({\n color: 'black',\n transparent: true,\n opacity: 0.5\n });\n this.cursorMesh = new THREE.Mesh(this.cursorGeo, this.cursorMat);\n this.cursor.setObject3D('mesh', this.cursorMesh);\n this.el.appendChild(this.cursor);\n },\n _emit: function (eventName, detail) {\n this.el.emit(eventName, detail);\n },\n _updateCharMetrics: function (event) {\n const layout = this.textAnchor.components.text.geometry.layout;\n const fontWidthFactor = event.detail.fontObj.widthFactor;\n this.charWidth = fontWidthFactor * this.textAnchor.object3DMap.text.scale.x;\n this.charHeight = this.charWidth * layout.lineHeight / fontWidthFactor;\n this.textAnchor.setAttribute('position', { x: 0, y: this.charHeight * this.data.rows / 2, z: 0 });\n this.background.setAttribute('scale', { x: 1.05, y: this.charHeight * this.data.rows * 1.05, z: 1 });\n this.background.setAttribute('position', { x: 0, y: 0, z: 0 });\n this._emit('char-metrics-changed');\n },\n _checkAndUpdateSelection: function () {\n if (\n this.selectionStart === this.textarea.selectionStart &&\n this.selectionEnd === this.textarea.selectionEnd\n ) {\n return;\n }\n\n const lastStart = this.selectionStart;\n const lastEnd = this.selectionEnd;\n\n this.selectionStart = this.textarea.selectionStart;\n this.selectionEnd = this.textarea.selectionEnd;\n\n this._emit('selection-changed', {\n start: { old: lastStart, new: this.selectionStart, changed: this.selectionStart !== lastStart },\n end: { old: lastEnd, new: this.selectionEnd, changed: this.selectionEnd !== lastEnd }\n });\n },\n _setShowCursorTimeout: function () {\n this.showCursorTimeout = 500;\n },\n tick: function (time, delta) {\n this._updateCursorVisibility(delta);\n this._checkAndUpdateSelection();\n this._checkAndUpdateText();\n },\n _updateCursorVisibility: function (delta) {\n if (document.activeElement === this.textarea) {\n if (this.showCursorTimeout > 0) {\n this.showCursorTimeout -= delta;\n this.cursorMesh.visible = true;\n } else {\n if (this.blinkEnabled) {\n if (Date.now() - this.lastBlink > 500) {\n this.cursorMesh.visible = !this.cursorMesh.visible;\n this.lastBlink = Date.now();\n }\n } else if (this.selectionStart !== this.selectionEnd) {\n this.cursorMesh.visible = true;\n }\n }\n } else {\n this.cursorMesh.visible = false;\n }\n },\n _getIndexInfo: function (lineIndex, textIndex) {\n const y = Math.max(0, lineIndex);\n const line = this.lines[y];\n const x = textIndex - line.start;\n return {\n line: line,\n x: x * this.charWidth,\n y: -this.charHeight * y + -this.charHeight / 2\n };\n },\n _updateIndexInfo: function () {\n if (!this.lines.length) {\n return;\n }\n const lastStart = this.startIndexInfo && this.startIndexInfo.line.index;\n const lastEnd = this.endIndexInfo && this.endIndexInfo.line.index;\n this.startIndexInfo = null;\n this.endIndexInfo = null;\n let i;\n let startChanged = false;\n let endChanged = false;\n for (i = 0; i <= this.lines.length; i++) {\n const prevLine = this.lines[i - 1];\n const lineStart = i === this.lines.length ? (prevLine.start + prevLine.length + 1) : this.lines[i].start;\n if (lineStart > this.selectionStart && !this.startIndexInfo) {\n this.startIndexInfo = this._getIndexInfo(i - 1, this.selectionStart);\n if (this.startIndexInfo.line.index !== lastStart) {\n startChanged = true;\n }\n }\n if (lineStart > this.selectionEnd) {\n this.endIndexInfo = this._getIndexInfo(i - 1, this.selectionEnd);\n if (this.endIndexInfo.line.index !== lastEnd) {\n endChanged = true;\n }\n break;\n }\n }\n if (startChanged || endChanged) {\n this._emit('index-info-changed', {\n start: { changed: startChanged },\n end: { changed: endChanged }\n });\n }\n },\n _updateOrigin: function (event) {\n let changed = false;\n if (event.detail.end.changed) {\n const end = this.origin.y + this.data.rows - 1;\n if (this.endIndexInfo.line.index > end) {\n this.origin.y = this.endIndexInfo.line.index + 1 - this.data.rows;\n changed = true;\n } else if (this.endIndexInfo.line.index < this.origin.y) {\n this.origin.y = this.endIndexInfo.line.index;\n changed = true;\n }\n }\n if (event.detail.start.changed) {\n if (this.startIndexInfo.line.index < this.origin.y) {\n this.origin.y = this.startIndexInfo.line.index;\n changed = true;\n }\n }\n if (changed) {\n this._emit('origin-changed');\n }\n },\n _updateHorizontalOrigin: function (event) {\n if (!this.endIndexInfo) {\n return;\n }\n let changed = true;\n if (event.detail.end.changed) {\n const endIndex = this.selectionEnd - this.endIndexInfo.line.start;\n if (endIndex > this.origin.x + this.data.cols) {\n this.origin.x = endIndex - this.data.cols;\n changed = true;\n } else if (endIndex < this.origin.x) {\n this.origin.x = endIndex;\n changed = true;\n }\n }\n const startIndex = this.selectionStart - this.startIndexInfo.line.start;\n if (event.detail.start.changed) {\n if (startIndex > this.origin.x + this.data.cols) {\n this.origin.x = startIndex - this.data.cols;\n changed = true;\n } else if (startIndex < this.origin.x) {\n this.origin.x = startIndex;\n changed = true;\n }\n }\n if (changed) {\n this._emit('origin-changed');\n }\n },\n _updateCursorStyle: function () {\n if (this.selectionStart === this.selectionEnd) {\n this.blinkEnabled = true;\n this.cursorMat.color.setStyle('black');\n this.cursorMat.transparent = false;\n } else {\n this.blinkEnabled = false;\n this.cursorMat.color.setStyle(this.data.selectionColor);\n this.cursorMesh.visible = true;\n this.cursorMat.transparent = true;\n }\n },\n _updateCursorGeometry: function () {\n if (!this.startIndexInfo) {\n return;\n }\n const startLine = Math.max(this.origin.y, this.startIndexInfo.line.index);\n const endLine = Math.min(this.origin.y + this.data.rows - 1, this.endIndexInfo.line.index);\n const maxIndex = this.origin.x + this.data.cols;\n const geos = [];\n const mesh = new THREE.Object3D();\n for (let i = startLine; i <= endLine; i++) {\n let size;\n let offset = 0;\n if (endLine === startLine) {\n offset = Math.max(this.origin.x, this.selectionStart - this.startIndexInfo.line.start);\n const end = Math.min(maxIndex, this.selectionEnd - this.startIndexInfo.line.start);\n size = Math.max(0.2, end - offset);\n } else {\n let end;\n if (i === this.startIndexInfo.line.index) {\n offset = Math.max(this.origin.x, this.selectionStart - this.startIndexInfo.line.start);\n end = Math.min(maxIndex, this.startIndexInfo.line.length);\n } else if (i === this.endIndexInfo.line.index) {\n offset = this.origin.x;\n end = Math.min(maxIndex, this.selectionEnd - this.endIndexInfo.line.start);\n } else {\n offset = this.origin.x;\n end = Math.min(maxIndex, this.lines[i].length);\n }\n size = end - offset;\n }\n mesh.scale.set(\n this.charWidth * size,\n this.charHeight,\n 1\n );\n mesh.position.set(\n offset * this.charWidth + this.charWidth * size / 2 - 0.5 - this.origin.x * this.charWidth,\n -i * this.charHeight + (this.charHeight * this.data.rows) / 2 - this.charHeight / 2 + this.origin.y * this.charHeight,\n 0.002\n );\n mesh.updateMatrix();\n const geo = new THREE.PlaneGeometry(1, 1);\n geo.applyMatrix4(mesh.matrix);\n geos.push(geo);\n }\n this.cursorMesh.geometry = THREE.BufferGeometryUtils.mergeBufferGeometries(geos);\n this.cursorMesh.geometry.verticesNeedUpdate = true;\n this.cursorMesh.geometry.needsUpdate = true;\n },\n _updateLines: function () {\n this.lines = [];\n const lines = this.text.split('\\n');\n let counter = 0;\n for (let i = 0; i < lines.length; i++) {\n this.lines[i] = {\n index: i,\n length: lines[i].length,\n start: counter\n };\n counter += lines[i].length + 1;\n }\n this._emit('lines-changed');\n },\n _getViewportText: function () {\n return this.text.split('\\n').slice(this.origin.y, this.origin.y + this.data.rows)\n .map(function (line) {\n return line.substr(this.origin.x, this.data.cols) || ' ';\n }.bind(this)).join('\\n');\n },\n _updateDisplayText: function () {\n this.textAnchor.setAttribute('text', {\n value: this._getViewportText()\n });\n },\n _checkAndUpdateText: function () {\n const text = this.textarea.value;\n if (text === this.text) {\n return;\n }\n this.text = text;\n this._emit('text-changed');\n }\n});\n\n\n//# sourceURL=webpack://aframe-textarea-component/./index.js?"); 19 | 20 | /***/ }) 21 | 22 | /******/ }); 23 | /************************************************************************/ 24 | /******/ 25 | /******/ // startup 26 | /******/ // Load entry module and return exports 27 | /******/ // This entry module can't be inlined because the eval devtool is used. 28 | /******/ var __webpack_exports__ = {}; 29 | /******/ __webpack_modules__["./index.js"](); 30 | /******/ 31 | /******/ })() 32 | ; -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* global AFRAME */ 2 | 3 | if (typeof AFRAME === 'undefined') { 4 | throw new Error('Component attempted to register before AFRAME was available.'); 5 | } 6 | 7 | /** 8 | * Textarea component for A-Frame. 9 | */ 10 | AFRAME.registerComponent('textarea', { 11 | schema: { 12 | cols: { type: 'int', default: 40 }, 13 | rows: { type: 'int', default: 20 }, 14 | color: { type: 'color', default: 'black' }, 15 | backgroundColor: { type: 'color', default: 'white' }, 16 | selectionColor: { type: 'color', default: 'grey' }, 17 | disabledBackgroundColor: { type: 'color', default: 'lightgrey' }, 18 | disabled: { type: 'boolean', default: false }, 19 | text: { type: 'string', default: '' } 20 | }, 21 | init: function () { 22 | this.text = null; 23 | this.lines = []; 24 | this.lastBlink = 0; 25 | this.showCursorTimeout = 0; 26 | this.blinkEnabled = !this.data.disabled; 27 | this.charWidth = this.charHeight = null; 28 | this.selectionStart = this.selectionEnd = 0; 29 | this.endIndexInfo = this.startIndexInfo = null; 30 | this.origin = { x: 0, y: 0 }; 31 | this.textarea = null; 32 | 33 | this.background = document.createElement('a-plane'); 34 | this.background.setAttribute('color', this.data.disabled ? this.data.disabledBackgroundColor : this.data.backgroundColor); 35 | this.el.appendChild(this.background); 36 | this.el.setObject3D('background', this.background.object3D); 37 | 38 | this.textAnchor = document.createElement('a-entity'); 39 | this.el.appendChild(this.textAnchor); 40 | this.textAnchor.setAttribute('text', { 41 | mode: 'pre', 42 | baseline: 'top', 43 | anchor: 'center', 44 | font: 'dejavu', 45 | wrapCount: this.data.cols, 46 | height: this.data.rows, 47 | color: this.data.color 48 | }); 49 | 50 | this._initTextarea(); 51 | 52 | this._initCursor(); 53 | 54 | this.el.addEventListener('textfontset', this._updateCharMetrics.bind(this)); 55 | this.el.addEventListener('char-metrics-changed', this._updateIndexInfo.bind(this)); 56 | this.el.addEventListener('char-metrics-changed', this._updateCursorGeometry.bind(this)); 57 | this.el.addEventListener('text-changed', this._updateLines.bind(this)); 58 | this.el.addEventListener('text-changed', this._updateDisplayText.bind(this)); 59 | this.el.addEventListener('text-changed', this._setShowCursorTimeout.bind(this)); 60 | this.el.addEventListener('selection-changed', this._updateIndexInfo.bind(this)); 61 | this.el.addEventListener('selection-changed', this._updateCursorStyle.bind(this)); 62 | this.el.addEventListener('selection-changed', this._updateCursorGeometry.bind(this)); 63 | this.el.addEventListener('selection-changed', this._updateHorizontalOrigin.bind(this)); 64 | this.el.addEventListener('selection-changed', this._setShowCursorTimeout.bind(this)); 65 | this.el.addEventListener('lines-changed', this._updateIndexInfo.bind(this)); 66 | this.el.addEventListener('index-info-changed', this._updateOrigin.bind(this)); 67 | this.el.addEventListener('index-info-changed', this._updateCursorGeometry.bind(this)); 68 | this.el.addEventListener('index-info-changed', this._updateHorizontalOrigin.bind(this)); 69 | this.el.addEventListener('origin-changed', this._updateCursorGeometry.bind(this)); 70 | this.el.addEventListener('origin-changed', this._updateDisplayText.bind(this)); 71 | this.el.addEventListener('click', this.focus.bind(this)); 72 | }, 73 | update: function (oldData) { 74 | if (this.data.text !== oldData.text) { 75 | this._updateTextarea(); 76 | } 77 | 78 | if (this.data.backgroundColor !== oldData.backgroundColor || this.data.disabledBackgroundColor !== oldData.disabledBackgroundColor) { 79 | this.background.setAttribute('color', this.data.disabled ? this.data.disabledBackgroundColor : this.data.backgroundColor); 80 | } 81 | 82 | if (this.data.disabled !== oldData.disabled) { 83 | this.blinkEnabled = !this.data.disabled; 84 | this.textarea.disabled = this.data.disabled; 85 | this.cursorMesh.visible = !this.data.disabled; 86 | this.background.setAttribute('color', this.data.disabled ? this.data.disabledBackgroundColor : this.data.backgroundColor); 87 | } 88 | }, 89 | focus: function () { 90 | this.textarea.focus(); 91 | }, 92 | blur: function () { 93 | this.textarea.blur(); 94 | }, 95 | getText: function () { 96 | return this.textarea.value; 97 | }, 98 | _initTextarea: function () { 99 | this.textarea = document.createElement('textarea'); 100 | document.body.appendChild(this.textarea); 101 | this._updateTextarea(); 102 | }, 103 | _updateTextarea: function () { 104 | this.textarea.style.whiteSpace = 'pre'; 105 | this.textarea.style.overflow = 'hidden'; 106 | this.textarea.style.opacity = '0'; 107 | 108 | this.textarea.cols = this.data.cols; 109 | this.textarea.rows = this.data.rows; 110 | this.textarea.value = this.data.text; 111 | this.textarea.selectionStart = 0; 112 | this.textarea.selectionEnd = 0; 113 | 114 | this._updateIndexInfo(); 115 | }, 116 | _initCursor: function () { 117 | this.cursor = document.createElement('a-entity'); 118 | this.cursorGeo = new THREE.PlaneGeometry(1, 1); 119 | this.cursorMat = new THREE.MeshBasicMaterial({ 120 | color: 'black', 121 | transparent: true, 122 | opacity: 0.5 123 | }); 124 | this.cursorMesh = new THREE.Mesh(this.cursorGeo, this.cursorMat); 125 | this.cursor.setObject3D('mesh', this.cursorMesh); 126 | this.el.appendChild(this.cursor); 127 | }, 128 | _emit: function (eventName, detail) { 129 | this.el.emit(eventName, detail); 130 | }, 131 | _updateCharMetrics: function (event) { 132 | const layout = this.textAnchor.components.text.geometry.layout; 133 | const fontWidthFactor = event.detail.fontObj.widthFactor; 134 | this.charWidth = fontWidthFactor * this.textAnchor.object3DMap.text.scale.x; 135 | this.charHeight = this.charWidth * layout.lineHeight / fontWidthFactor; 136 | this.textAnchor.setAttribute('position', { x: 0, y: this.charHeight * this.data.rows / 2, z: 0 }); 137 | this.background.setAttribute('scale', { x: 1.05, y: this.charHeight * this.data.rows * 1.05, z: 1 }); 138 | this.background.setAttribute('position', { x: 0, y: 0, z: 0 }); 139 | this._emit('char-metrics-changed'); 140 | }, 141 | _checkAndUpdateSelection: function () { 142 | if ( 143 | this.selectionStart === this.textarea.selectionStart && 144 | this.selectionEnd === this.textarea.selectionEnd 145 | ) { 146 | return; 147 | } 148 | 149 | const lastStart = this.selectionStart; 150 | const lastEnd = this.selectionEnd; 151 | 152 | this.selectionStart = this.textarea.selectionStart; 153 | this.selectionEnd = this.textarea.selectionEnd; 154 | 155 | this._emit('selection-changed', { 156 | start: { old: lastStart, new: this.selectionStart, changed: this.selectionStart !== lastStart }, 157 | end: { old: lastEnd, new: this.selectionEnd, changed: this.selectionEnd !== lastEnd } 158 | }); 159 | }, 160 | _setShowCursorTimeout: function () { 161 | this.showCursorTimeout = 500; 162 | }, 163 | tick: function (time, delta) { 164 | this._updateCursorVisibility(delta); 165 | this._checkAndUpdateSelection(); 166 | this._checkAndUpdateText(); 167 | }, 168 | _updateCursorVisibility: function (delta) { 169 | if (document.activeElement === this.textarea) { 170 | if (this.showCursorTimeout > 0) { 171 | this.showCursorTimeout -= delta; 172 | this.cursorMesh.visible = true; 173 | } else { 174 | if (this.blinkEnabled) { 175 | if (Date.now() - this.lastBlink > 500) { 176 | this.cursorMesh.visible = !this.cursorMesh.visible; 177 | this.lastBlink = Date.now(); 178 | } 179 | } else if (this.selectionStart !== this.selectionEnd) { 180 | this.cursorMesh.visible = true; 181 | } 182 | } 183 | } else { 184 | this.cursorMesh.visible = false; 185 | } 186 | }, 187 | _getIndexInfo: function (lineIndex, textIndex) { 188 | const y = Math.max(0, lineIndex); 189 | const line = this.lines[y]; 190 | const x = textIndex - line.start; 191 | return { 192 | line: line, 193 | x: x * this.charWidth, 194 | y: -this.charHeight * y + -this.charHeight / 2 195 | }; 196 | }, 197 | _updateIndexInfo: function () { 198 | if (!this.lines.length) { 199 | return; 200 | } 201 | const lastStart = this.startIndexInfo && this.startIndexInfo.line.index; 202 | const lastEnd = this.endIndexInfo && this.endIndexInfo.line.index; 203 | this.startIndexInfo = null; 204 | this.endIndexInfo = null; 205 | let i; 206 | let startChanged = false; 207 | let endChanged = false; 208 | for (i = 0; i <= this.lines.length; i++) { 209 | const prevLine = this.lines[i - 1]; 210 | const lineStart = i === this.lines.length ? (prevLine.start + prevLine.length + 1) : this.lines[i].start; 211 | if (lineStart > this.selectionStart && !this.startIndexInfo) { 212 | this.startIndexInfo = this._getIndexInfo(i - 1, this.selectionStart); 213 | if (this.startIndexInfo.line.index !== lastStart) { 214 | startChanged = true; 215 | } 216 | } 217 | if (lineStart > this.selectionEnd) { 218 | this.endIndexInfo = this._getIndexInfo(i - 1, this.selectionEnd); 219 | if (this.endIndexInfo.line.index !== lastEnd) { 220 | endChanged = true; 221 | } 222 | break; 223 | } 224 | } 225 | if (startChanged || endChanged) { 226 | this._emit('index-info-changed', { 227 | start: { changed: startChanged }, 228 | end: { changed: endChanged } 229 | }); 230 | } 231 | }, 232 | _updateOrigin: function (event) { 233 | let changed = false; 234 | if (event.detail.end.changed) { 235 | const end = this.origin.y + this.data.rows - 1; 236 | if (this.endIndexInfo.line.index > end) { 237 | this.origin.y = this.endIndexInfo.line.index + 1 - this.data.rows; 238 | changed = true; 239 | } else if (this.endIndexInfo.line.index < this.origin.y) { 240 | this.origin.y = this.endIndexInfo.line.index; 241 | changed = true; 242 | } 243 | } 244 | if (event.detail.start.changed) { 245 | if (this.startIndexInfo.line.index < this.origin.y) { 246 | this.origin.y = this.startIndexInfo.line.index; 247 | changed = true; 248 | } 249 | } 250 | if (changed) { 251 | this._emit('origin-changed'); 252 | } 253 | }, 254 | _updateHorizontalOrigin: function (event) { 255 | if (!this.endIndexInfo) { 256 | return; 257 | } 258 | let changed = true; 259 | if (event.detail.end.changed) { 260 | const endIndex = this.selectionEnd - this.endIndexInfo.line.start; 261 | if (endIndex > this.origin.x + this.data.cols) { 262 | this.origin.x = endIndex - this.data.cols; 263 | changed = true; 264 | } else if (endIndex < this.origin.x) { 265 | this.origin.x = endIndex; 266 | changed = true; 267 | } 268 | } 269 | const startIndex = this.selectionStart - this.startIndexInfo.line.start; 270 | if (event.detail.start.changed) { 271 | if (startIndex > this.origin.x + this.data.cols) { 272 | this.origin.x = startIndex - this.data.cols; 273 | changed = true; 274 | } else if (startIndex < this.origin.x) { 275 | this.origin.x = startIndex; 276 | changed = true; 277 | } 278 | } 279 | if (changed) { 280 | this._emit('origin-changed'); 281 | } 282 | }, 283 | _updateCursorStyle: function () { 284 | if (this.selectionStart === this.selectionEnd) { 285 | this.blinkEnabled = true; 286 | this.cursorMat.color.setStyle('black'); 287 | this.cursorMat.transparent = false; 288 | } else { 289 | this.blinkEnabled = false; 290 | this.cursorMat.color.setStyle(this.data.selectionColor); 291 | this.cursorMesh.visible = true; 292 | this.cursorMat.transparent = true; 293 | } 294 | }, 295 | _updateCursorGeometry: function () { 296 | if (!this.startIndexInfo) { 297 | return; 298 | } 299 | const startLine = Math.max(this.origin.y, this.startIndexInfo.line.index); 300 | const endLine = Math.min(this.origin.y + this.data.rows - 1, this.endIndexInfo.line.index); 301 | const maxIndex = this.origin.x + this.data.cols; 302 | const geos = []; 303 | const mesh = new THREE.Object3D(); 304 | for (let i = startLine; i <= endLine; i++) { 305 | let size; 306 | let offset = 0; 307 | if (endLine === startLine) { 308 | offset = Math.max(this.origin.x, this.selectionStart - this.startIndexInfo.line.start); 309 | const end = Math.min(maxIndex, this.selectionEnd - this.startIndexInfo.line.start); 310 | size = Math.max(0.2, end - offset); 311 | } else { 312 | let end; 313 | if (i === this.startIndexInfo.line.index) { 314 | offset = Math.max(this.origin.x, this.selectionStart - this.startIndexInfo.line.start); 315 | end = Math.min(maxIndex, this.startIndexInfo.line.length); 316 | } else if (i === this.endIndexInfo.line.index) { 317 | offset = this.origin.x; 318 | end = Math.min(maxIndex, this.selectionEnd - this.endIndexInfo.line.start); 319 | } else { 320 | offset = this.origin.x; 321 | end = Math.min(maxIndex, this.lines[i].length); 322 | } 323 | size = end - offset; 324 | } 325 | mesh.scale.set( 326 | this.charWidth * size, 327 | this.charHeight, 328 | 1 329 | ); 330 | mesh.position.set( 331 | offset * this.charWidth + this.charWidth * size / 2 - 0.5 - this.origin.x * this.charWidth, 332 | -i * this.charHeight + (this.charHeight * this.data.rows) / 2 - this.charHeight / 2 + this.origin.y * this.charHeight, 333 | 0.002 334 | ); 335 | mesh.updateMatrix(); 336 | const geo = new THREE.PlaneGeometry(1, 1); 337 | geo.applyMatrix4(mesh.matrix); 338 | geos.push(geo); 339 | } 340 | this.cursorMesh.geometry = THREE.BufferGeometryUtils.mergeBufferGeometries(geos); 341 | this.cursorMesh.geometry.verticesNeedUpdate = true; 342 | this.cursorMesh.geometry.needsUpdate = true; 343 | }, 344 | _updateLines: function () { 345 | this.lines = []; 346 | const lines = this.text.split('\n'); 347 | let counter = 0; 348 | for (let i = 0; i < lines.length; i++) { 349 | this.lines[i] = { 350 | index: i, 351 | length: lines[i].length, 352 | start: counter 353 | }; 354 | counter += lines[i].length + 1; 355 | } 356 | this._emit('lines-changed'); 357 | }, 358 | _getViewportText: function () { 359 | return this.text.split('\n').slice(this.origin.y, this.origin.y + this.data.rows) 360 | .map(function (line) { 361 | return line.substr(this.origin.x, this.data.cols) || ' '; 362 | }.bind(this)).join('\n'); 363 | }, 364 | _updateDisplayText: function () { 365 | this.textAnchor.setAttribute('text', { 366 | value: this._getViewportText() 367 | }); 368 | }, 369 | _checkAndUpdateText: function () { 370 | const text = this.textarea.value; 371 | if (text === this.text) { 372 | return; 373 | } 374 | this.text = text; 375 | this._emit('text-changed'); 376 | } 377 | }); 378 | --------------------------------------------------------------------------------