Click + Drag component

This commit is contained in:
Kevin Ngo 2015-12-18 15:36:21 -08:00 committed by Jess Telford
commit f446d1bb11
12 changed files with 804 additions and 0 deletions

2
.eslintignore Normal file
View file

@ -0,0 +1,2 @@
**/*.json
/lib/

24
.eslintrc Normal file
View file

@ -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,
}
}
}

53
.gitignore vendored Normal file
View file

@ -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

52
.npmignore Normal file
View file

@ -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

22
LICENSE Normal file
View file

@ -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.

47
README.md Normal file
View file

@ -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
<head>
<script src="https://aframe.io/releases/0.3.0/aframe.min.js"></script>
<script src="https://unpkg.com/aframe-click-drag-component"></script>
<script>
registerAframeClickDragComponent(window.AFRAME);
</script>
</head>
<body>
<a-scene>
<a-sphere click-drag position="0 1.25 -5" radius="1.25" color="#EF2D5E"></a-sphere>
<a-camera look-controls-enabled="false"></a-camera>
</a-scene>
</body>
```
#### 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);
```

1
dist/.gitkeep vendored Normal file
View file

@ -0,0 +1 @@
`npm run dist` to generate browser files.

21
examples/basic/index.html Normal file
View file

@ -0,0 +1,21 @@
<html>
<head>
<title>A-Frame Click & Drag Component - Basic</title>
<script src="../build.js"></script>
</head>
<body>
<a-scene>
<a-sphere click-drag position="0 1.25 -1" radius="1.25" color="#EF2D5E"></a-sphere>
<a-box click-drag position="-1 0.5 1" rotation="0 45 0" width="1" height="1" depth="1" color="#4CC3D9"></a-box>
<a-cylinder click-drag position="1 0.75 1" radius="0.5" height="1.5" color="#FFC65D"></a-cylinder>
<a-plane rotation="-90 0 0" width="4" height="4" color="#7BC8A4"></a-plane>
<a-sky color="#ECECEC"></a-sky>
<a-entity position="0 0 3.8">
<a-camera look-controls-enabled="false"></a-camera>
</a-entity>
</a-scene>
</body>
</html>

37
examples/index.html Normal file
View file

@ -0,0 +1,37 @@
<html>
<head>
<title>A-Frame Click & Drag Component</title>
<style>
html {
background: #33425B;
color: #FAFAFA;
font-family: monospace;
font-size: 20px;
padding: 10px 20px;
}
h1 {
font-weight: 300;
}
a {
color: #FAFAFA;
display: block;
padding: 15px 0;
}
</style>
</head>
<body>
<h1>A-Frame Click & Drag Component</h1>
<a href="basic/">Demo</a>
<p>Click + Drag entities on the screen. Note the plane cannot be dragged (it does not have the "click-drag" attribute).</p>
<p>Try the WASD keys to move around while dragging an entity!</p>
<!-- GitHub Corner. -->
<a href="https://github.com/jesstelford/aframe-click-drag-component" class="github-corner">
<svg width="80" height="80" viewBox="0 0 250 250" style="fill:#222; color:#fff; position: absolute; top: 0; border: 0; right: 0;">
<path d="M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z"></path><path d="M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2" fill="currentColor" style="transform-origin: 130px 106px;" class="octo-arm"></path><path d="M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z" fill="currentColor" class="octo-body"></path>
</svg>
</a>
<style>.github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}}
</style>
</body>
</html>

3
examples/main.js Normal file
View file

@ -0,0 +1,3 @@
import aframe from 'aframe';
import clickDragComponent from '../src/index';
clickDragComponent(aframe);

74
package.json Normal file
View file

@ -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 <hi@jes.st>",
"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"
]
}
}

468
src/index.js Normal file
View file

@ -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);
},
});
};