From f446d1bb11931c1582b2d21b84311b72b618f3e2 Mon Sep 17 00:00:00 2001 From: Kevin Ngo Date: Fri, 18 Dec 2015 15:36:21 -0800 Subject: [PATCH] Click + Drag component --- .eslintignore | 2 + .eslintrc | 24 ++ .gitignore | 53 +++++ .npmignore | 52 +++++ LICENSE | 22 ++ README.md | 47 ++++ dist/.gitkeep | 1 + examples/basic/index.html | 21 ++ examples/index.html | 37 +++ examples/main.js | 3 + package.json | 74 ++++++ src/index.js | 468 ++++++++++++++++++++++++++++++++++++++ 12 files changed, 804 insertions(+) create mode 100644 .eslintignore create mode 100644 .eslintrc create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 dist/.gitkeep create mode 100644 examples/basic/index.html create mode 100644 examples/index.html create mode 100644 examples/main.js create mode 100644 package.json create mode 100644 src/index.js diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..1605b3a --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +**/*.json +/lib/ diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..6be91d2 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,24 @@ +{ + "env": { + "browser": true + }, + "extends": "airbnb", + "rules": { + "react/prefer-stateless-function": "off", + "react/sort-comp": "off", + "no-param-reassign": "off", + "padded-blocks": "off", + + "react/jsx-filename-extension": ["error", { "extensions": [".js", ".jsx"] }], + "react/prefer-es6-class": ["error", "never"], + "object-curly-spacing": ["error", "never"], + "no-underscore-dangle": ["error", { "allowAfterThis": true }], + "no-unused-vars": ["error", { "argsIgnorePattern": "_" }], + "new-cap": ["error", { "capIsNewExceptions": ["React.Children"] }] + }, + "parserOptions": { + "ecmaFeatures": { + "experimentalObjectRestSpread": true, + } + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ce5590 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +dist/ +lib/ +.sw[ponm] +examples/build.js +examples/node_modules/ +gh-pages +node_modules/ +npm-debug.log + +# Created by https://www.gitignore.io/api/node + +### Node ### +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..aab6749 --- /dev/null +++ b/.npmignore @@ -0,0 +1,52 @@ +src/ +.sw[ponm] +examples/build.js +examples/node_modules/ +gh-pages +node_modules/ +npm-debug.log + +# Created by https://www.gitignore.io/api/node + +### Node ### +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..602ebee --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015 Kevin Ngo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3dfde2 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# aframe-click-drag-component + +A Click & Drag component for [A-Frame](https://aframe.io). + +Entities with the `click-drag` component can be click and dragged around the 3D +scene. Even works whle the camera is moving! + +_Note: entities are not positioned correctly when the camera is rotated._ + +### Installation + +#### Browser + +Use directly from the unpkg CDN: + +```html + + + + + + + + + + + + +``` + +#### npm + +Install via npm: + +```bash +npm install aframe-click-drag-component +``` + +Then register and use. + +```javascript +import aframe from 'aframe'; +import registerClickDrag from 'aframe-click-drag-component'; +registerClickDrag(aframe); +``` diff --git a/dist/.gitkeep b/dist/.gitkeep new file mode 100644 index 0000000..d9186ea --- /dev/null +++ b/dist/.gitkeep @@ -0,0 +1 @@ +`npm run dist` to generate browser files. diff --git a/examples/basic/index.html b/examples/basic/index.html new file mode 100644 index 0000000..d769c36 --- /dev/null +++ b/examples/basic/index.html @@ -0,0 +1,21 @@ + + + A-Frame Click & Drag Component - Basic + + + + + + + + + + + + + + + + + + diff --git a/examples/index.html b/examples/index.html new file mode 100644 index 0000000..02c22d4 --- /dev/null +++ b/examples/index.html @@ -0,0 +1,37 @@ + + + A-Frame Click & Drag Component + + + +

A-Frame Click & Drag Component

+ Demo +

Click + Drag entities on the screen. Note the plane cannot be dragged (it does not have the "click-drag" attribute).

+

Try the WASD keys to move around while dragging an entity!

+ + + + + + + + + + diff --git a/examples/main.js b/examples/main.js new file mode 100644 index 0000000..9e5ebc8 --- /dev/null +++ b/examples/main.js @@ -0,0 +1,3 @@ +import aframe from 'aframe'; +import clickDragComponent from '../src/index'; +clickDragComponent(aframe); diff --git a/package.json b/package.json new file mode 100644 index 0000000..f4fc095 --- /dev/null +++ b/package.json @@ -0,0 +1,74 @@ +{ + "name": "aframe-click-drag-component", + "version": "0.0.0", + "description": "Click & Drag component for A-Frame.", + "main": "lib/index.js", + "browser": "dist/aframe-click-drag-component.min.js", + "scripts": { + "build-example": "browserify examples/main.js --debug --verbose -t babelify -t [envify --NODE_ENV development ] -o examples/build.js", + "build-lib": "mkdir -p lib && babel src/index.js -o lib/build.js", + "dist": "browserify src/index.js --verbose --debug --standalone registerAframeClickDragComponent -g uglifyify -t rollupify -t babelify -t [envify --NODE_ENV production ] | exorcist dist/out.map > dist/out.js && uglifyjs dist/out.js --screw-ie8 -c -m --in-source-map dist/out.map --source-map dist/aframe-click-drag-component.min.js.map --source-map-url aframe-click-drag-component.min.js.map > dist/aframe-click-drag-component.min.js && rm dist/out*", + "test": "npm run test:lint", + "test:lint": "eslint .", + "start": "budo examples/main.js:../build.js --serve build.js --dir examples --port 8000 --live --open -- --debug --verbose -t babelify -t [envify --NODE_ENV development ]", + "prepublish": "in-publish && npm run dist && npm run build-lib || not-in-publish", + "preghpages": "npm run build-example && rm -rf gh-pages && mkdir gh-pages && cp -r examples/* gh-pages", + "ghpages": "npm run preghpages && ghpages -p gh-pages" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/jesstelford/aframe-click-drag-component.git" + }, + "keywords": [ + "aframe", + "aframe-component", + "aframe-vr", + "vr", + "mozvr", + "webvr" + ], + "author": "Jess Telford ", + "license": "MIT", + "bugs": { + "url": "https://github.com/jesstelford/aframe-click-drag-component/issues" + }, + "homepage": "https://github.com/jesstelford/aframe-click-drag-component#readme", + "peerDependencies": { + "aframe": "^0.3.0" + }, + "devDependencies": { + "aframe": "^0.3.0", + "babel-cli": "^6.14.0", + "babel-plugin-transform-object-rest-spread": "^6.8.0", + "babel-preset-es2015": "^6.9.0", + "babel-preset-stage-0": "^6.5.0", + "babelify": "^7.3.0", + "browserify": "^13.1.0", + "browserify-css": "^0.9.1", + "budo": "^9.2.0", + "envify": "^3.4.1", + "eslint": "^3.2.2", + "eslint-config-airbnb": "^10.0.0", + "eslint-plugin-import": "^1.12.0", + "eslint-plugin-jsx-a11y": "^2.0.1", + "eslint-plugin-react": "^6.2.0", + "exorcist": "^0.4.0", + "ghpages": "^0.0.8", + "in-publish": "^2.0.0", + "rollupify": "^0.3.4", + "uglify-js": "^2.7.3", + "uglifyify": "^3.0.3" + }, + "dependencies": { + "deep-equal": "^1.0.1" + }, + "babel": { + "presets": [ + "es2015", + "stage-0" + ], + "plugins": [ + "transform-object-rest-spread" + ] + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..e59797b --- /dev/null +++ b/src/index.js @@ -0,0 +1,468 @@ +import deepEqual from 'deep-equal'; + +const COMPONENT_NAME = 'click-drag'; + +function cameraPositionToVec3(camera, vec3) { + + let element = camera; + + vec3.set( + element.components.position.data.x, + element.components.position.data.y, + element.components.position.data.z + ); + + while (element.attachedToParent) { + + element = element.parentElement; + + if (element.components && element.components.position) { + vec3.set( + vec3.x + element.components.position.data.x, + vec3.y + element.components.position.data.y, + vec3.z + element.components.position.data.z + ); + } + + } + +} + +const {unproject} = (function unprojectFunction() { + + let initialized = false; + + let cameraPosition; + + let cameraWorld; + let matrix; + + function initialize(THREE) { + cameraPosition = new THREE.Vector3(); + + cameraWorld = new THREE.Matrix4(); + matrix = new THREE.Matrix4(); + + return true; + } + + return { + + unproject(THREE, vector, camera) { + + initialized = initialized || initialize(THREE); + + cameraPositionToVec3(camera, cameraPosition); + + cameraWorld.identity(); + cameraWorld.setPosition(cameraPosition); + + matrix.multiplyMatrices( + cameraWorld, + matrix.getInverse(camera.components.camera.camera.projectionMatrix) + ); + + return vector.applyProjection(matrix); + + }, + }; +}()); + +function clientCoordsTo3DCanvasCoords( + clientX, + clientY, + offsetX, + offsetY, + clientWidth, + clientHeight +) { + return { + x: (((clientX - offsetX) / clientWidth) * 2) - 1, + y: (-((clientY - offsetY) / clientHeight) * 2) + 1, + }; +} + +const {screenCoordsToDirection} = (function screenCoordsToDirectionFunction() { + + let initialized = false; + + let mousePosAsVec3; + let cameraPosAsVec3; + + function initialize(THREE) { + mousePosAsVec3 = new THREE.Vector3(); + cameraPosAsVec3 = new THREE.Vector3(); + + return true; + } + + return { + screenCoordsToDirection( + THREE, + aframeCamera, + {x: clientX, y: clientY} + ) { + + initialized = initialized || initialize(THREE); + + // scale mouse coordinates down to -1 <-> +1 + const {x: mouseX, y: mouseY} = clientCoordsTo3DCanvasCoords( + clientX, clientY, + 0, 0, // TODO: Replace with canvas position + window.innerWidth, + window.innerHeight + ); + + mousePosAsVec3.set(mouseX, mouseY, -1); + + // apply camera transformation from near-plane of mouse x/y into 3d space + // NOTE: This should be replaced with THREE code directly once the aframe bug + // is fixed: + // const projectedVector = new THREE + // .Vector3(mouseX, mouseY, -1) + // .unproject(threeCamera); + const projectedVector = unproject(THREE, mousePosAsVec3, aframeCamera); + + cameraPositionToVec3(aframeCamera, cameraPosAsVec3); + + // Get the unit length direction vector from the camera's position + const {x, y, z} = projectedVector.sub(cameraPosAsVec3).normalize(); + + return {x, y, z}; + }, + }; +}()); + +/** + * @param planeNormal {THREE.Vector3} + * @param planeConstant {Float} Distance from origin of the plane + * @param rayDirection {THREE.Vector3} Direction of ray from the origin + * + * @return {THREE.Vector3} The intersection point of the ray and plane + */ +function rayPlaneIntersection(planeNormal, planeConstant, rayDirection) { + // A line from the camera position toward (and through) the plane + const distanceToPlane = planeConstant / planeNormal.dot(rayDirection); + return rayDirection.multiplyScalar(distanceToPlane); +} + +const {directionToWorldCoords} = (function directionToWorldCoordsFunction() { + + let initialized = false; + + let direction; + let cameraPosAsVec3; + + function initialize(THREE) { + direction = new THREE.Vector3(); + cameraPosAsVec3 = new THREE.Vector3(); + + return true; + } + + return { + /** + * @param camera Three.js Camera instance + * @param Object Position of the camera + * @param Object position of the mouse (scaled to be between -1 to 1) + * @param depth Depth into the screen to calculate world coordinates for + */ + directionToWorldCoords( + THREE, + aframeCamera, + camera, + {x: directionX, y: directionY, z: directionZ}, + depth + ) { + + initialized = initialized || initialize(THREE); + + cameraPositionToVec3(aframeCamera, cameraPosAsVec3); + direction.set(directionX, directionY, directionZ); + + // A line from the camera position toward (and through) the plane + const newPosition = rayPlaneIntersection( + camera.getWorldDirection(), + depth, + direction + ); + + // Reposition back to the camera position + const {x, y, z} = newPosition.add(cameraPosAsVec3); + + return {x, y, z}; + + }, + }; +}()); + +const {selectItem} = (function selectItemFunction() { + + let initialized = false; + + let cameraPosAsVec3; + let directionAsVec3; + let raycaster; + let plane; + + function initialize(THREE) { + plane = new THREE.Plane(); + cameraPosAsVec3 = new THREE.Vector3(); + directionAsVec3 = new THREE.Vector3(); + raycaster = new THREE.Raycaster(); + + // TODO: From camera values? + raycaster.far = Infinity; + raycaster.near = 0; + + return true; + } + + return { + selectItem(THREE, selector, camera, clientX, clientY) { + + initialized = initialized || initialize(THREE); + + const {x: directionX, y: directionY, z: directionZ} = screenCoordsToDirection( + THREE, + camera, + {x: clientX, y: clientY} + ); + + cameraPositionToVec3(camera, cameraPosAsVec3); + directionAsVec3.set(directionX, directionY, directionZ); + + raycaster.set(cameraPosAsVec3, directionAsVec3); + + // Push meshes onto list of objects to intersect. + // TODO: Can we do this at some other point instead of every time a ray is + // cast? Is that a micro optimization? + const objects = Array.from( + camera.sceneEl.querySelectorAll(`[${selector}]`) + ).map(object => object.object3D); + + const recursive = true; + + const intersected = raycaster + .intersectObjects(objects, recursive) + // Only keep intersections against objects that have a reference to an entity. + .filter(intersection => !!intersection.object.el) + // Only keep ones that are visible + .filter(intersection => intersection.object.parent.visible) + // The first element is the closest + [0]; // eslint-disable-line no-unexpected-multiline + + if (!intersected) { + return {}; + } + + const {point, object} = intersected; + + // Aligned to the world direction of the camera + // At the specified intersection point + plane.setFromNormalAndCoplanarPoint( + camera.components.camera.camera.getWorldDirection().clone().negate(), + point.clone().sub(cameraPosAsVec3) + ); + + const depth = plane.constant; + + const offset = point.sub(object.getWorldPosition()); + + return {depth, offset, element: object.el}; + + }, + }; +}()); + +function dragItem(THREE, element, offset, camera, depth, mouseInfo) { + + const {x: offsetX, y: offsetY, z: offsetZ} = offset; + let lastMouseInfo = mouseInfo; + + function onMouseMove({clientX, clientY}) { + + lastMouseInfo = {clientX, clientY}; + + const direction = screenCoordsToDirection( + THREE, + camera, + {x: clientX, y: clientY} + ); + + const {x, y, z} = directionToWorldCoords( + THREE, + camera, + camera.components.camera.camera, + direction, + depth + ); + + element.setAttribute('position', {x: x - offsetX, y: y - offsetY, z: z - offsetZ}); + } + + function onCameraMove({detail}) { + if (detail.name === 'position' && !deepEqual(detail.oldData, detail.newData)) { + onMouseMove(lastMouseInfo); + } + } + + document.addEventListener('mousemove', onMouseMove); + camera.addEventListener('componentchanged', onCameraMove); + + // The "unlisten" function + return _ => { + document.removeEventListener('mousemove', onMouseMove); + camera.removeEventListener('componentchanged', onCameraMove); + }; +} + +// Closure to close over the removal of the event listeners +const {initialize, tearDown} = (function closeOverInitAndTearDown() { + + let removeClickListeners; + + return { + initialize(THREE, componentName) { + + // TODO: Based on a scene from the element passed in? + const scene = document.querySelector('a-scene'); + // delay loading of this as we're not 100% if the scene has loaded yet or not + let camera; + let removeDragListeners; + + function onMouseDown({clientX, clientY}) { + + const {depth, offset, element} = selectItem(THREE, componentName, camera, clientX, clientY); + + if (element) { + // Can only drag one item at a time, so no need to check if any + // listener is already set up + removeDragListeners = dragItem(THREE, element, offset, camera, depth, {clientX, clientY}); + } + } + + function onMouseUp() { + removeDragListeners && removeDragListeners(); // eslint-disable-line no-unused-expressions + removeDragListeners = undefined; + } + + function run() { + + camera = scene.camera.el; + + // TODO: Attach to canvas? + document.addEventListener('mousedown', onMouseDown); + document.addEventListener('mouseup', onMouseUp); + + removeClickListeners = _ => { + document.removeEventListener('mousedown', onMouseDown); + document.removeEventListener('mouseup', onMouseUp); + }; + + } + + if (scene.hasLoaded) { + run(); + } else { + scene.addEventListener('loaded', run); + } + + }, + + tearDown() { + removeClickListeners && removeClickListeners(); // eslint-disable-line no-unused-expressions + removeClickListeners = undefined; + }, + }; +}()); + +const {didMount, didUnmount} = (function getDidMountAndUnmount() { + + const cache = []; + + return { + didMount(element, THREE, componentName) { + + if (cache.length === 0) { + initialize(THREE, componentName); + } + + if (cache.indexOf(element) === -1) { + cache.push(element); + } + }, + + didUnmount(element) { + + const cacheIndex = cache.indexOf(element); + + if (cacheIndex === -1) { + return; + } + + // remove that element + cache.splice(cacheIndex, 1); + + if (cache.length === 0) { + tearDown(); + } + + }, + }; +}()); + +/** + * @param aframe {Object} The Aframe instance to register with + * @param componentName {String} The component name to use. Default: 'click-drag' + */ +export default function aframeDraggableComponent(aframe, componentName = COMPONENT_NAME) { + + const THREE = aframe.THREE; + + /** + * Draggable component for A-Frame. + */ + aframe.registerComponent(componentName, { + schema: { }, + + /** + * Called once when component is attached. Generally for initial setup. + */ + init() { + didMount(this, THREE, componentName); + }, + + /** + * Called when component is attached and when component data changes. + * Generally modifies the entity based on the data. + * + * @param oldData + */ + update() { }, + + /** + * Called when a component is removed (e.g., via removeAttribute). + * Generally undoes all modifications to the entity. + */ + remove() { + didUnmount(); + }, + + /** + * Called when entity pauses. + * Use to stop or remove any dynamic or background behavior such as events. + */ + pause() { + didUnmount(); + }, + + /** + * Called when entity resumes. + * Use to continue or add any dynamic or background behavior such as events. + */ + play() { + didMount(this, THREE, componentName); + }, + }); +};