diff --git a/src/assets/stylesheets/create-object-dialog.scss b/src/assets/stylesheets/create-object-dialog.scss new file mode 100644 index 0000000000000000000000000000000000000000..9500fa41e328409b46915719dfa3f3cb5f4de6b0 --- /dev/null +++ b/src/assets/stylesheets/create-object-dialog.scss @@ -0,0 +1,74 @@ +@import 'shared'; + +:local(.add-media-form) { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + margin: 0; +} + +:local(.action-button) { + @extend %bottom-action-button; + margin-left: 6px; + margin-right: 6px; + appearance: none; + width: 128px; + text-align: center; + -moz-appearance: none; + -webkit-appearance: none; +} + +:local(.buttons) { + display: flex; + flex-direction: row; + align-items: center; +} + +:local(.small-button) { + margin-left: 0.25em; + font-size: 2em; + align-self: center; +} + +:local(.cancel-icon) { + color: white; + &:hover { + color: #FF3D7F + } +} + +:local(.upload-icon) { + color: white; + &:hover { + color: #2F80ED; + } +} + +:local(.input-border) { + display: flex; + border: 0.25em solid white; + border-radius: 1em; + margin: 1em; + padding: 0.5em 0.75em; + width: 100%; + box-sizing: border-box; + @extend %default-font; +} + +:local(.left-side-of-input) { + flex-grow: 1; + border: none; + white-space: nowrap; + background: transparent; + color: white; + font-size: 1.2em; + align-self: center; + overflow: hidden; + text-overflow: ellipsis; +} + +:local(.hide-file-input) { + visibility: hidden; + position: absolute; +} diff --git a/src/components/gltf-model-plus.js b/src/components/gltf-model-plus.js index b90e236874a1a121f81527da664dcee9edbe815e..bc0457d6559631f241714439dd1de8cd345d014f 100644 --- a/src/components/gltf-model-plus.js +++ b/src/components/gltf-model-plus.js @@ -215,19 +215,18 @@ async function resolveGLTFUri(gltfProperty, basePath) { if (url.protocol === "blob:") { gltfProperty.uri = url.href; } else { - const { raw } = await resolveMedia(url.href); + const { raw } = await resolveMedia(url.href, null, true); gltfProperty.uri = raw; } } -async function loadGLTF(src, preferredTechnique, onProgress) { - const { raw, origin, contentType } = await resolveMedia(src); - const basePath = THREE.LoaderUtils.extractUrlBase(origin); +async function loadGLTF(src, token, contentType, preferredTechnique, onProgress) { + const basePath = THREE.LoaderUtils.extractUrlBase(src); - let gltfUrl = raw; + let gltfUrl = src; let fileMap; - if (contentType === "model/gltf+zip") { + if (contentType.includes("model/gltf+zip") || contentType.includes("application/x-zip-compressed")) { fileMap = await getFilesFromSketchfabZip(gltfUrl); gltfUrl = fileMap["scene.gtlf"]; } @@ -247,13 +246,17 @@ async function loadGLTF(src, preferredTechnique, onProgress) { if (images) { for (const image of images) { - pendingFarsparkPromises.push(resolveGLTFUri(image, parser.options.path)); + if (image.uri) { + pendingFarsparkPromises.push(resolveGLTFUri(image, parser.options.path)); + } } } if (buffers) { for (const buffer of buffers) { - pendingFarsparkPromises.push(resolveGLTFUri(buffer, parser.options.path)); + if (buffer.uri) { + pendingFarsparkPromises.push(resolveGLTFUri(buffer, parser.options.path)); + } } } @@ -278,8 +281,6 @@ async function loadGLTF(src, preferredTechnique, onProgress) { await Promise.all(pendingFarsparkPromises); - console.log(parser.json); - const gltf = await new Promise((resolve, reject) => parser.parse( (scene, scenes, cameras, animations, json) => { @@ -317,6 +318,8 @@ async function loadGLTF(src, preferredTechnique, onProgress) { AFRAME.registerComponent("gltf-model-plus", { schema: { src: { type: "string" }, + token: { type: "string" }, + contentType: { type: "string" }, inflate: { default: false } }, @@ -327,7 +330,7 @@ AFRAME.registerComponent("gltf-model-plus", { }, update() { - this.applySrc(this.data.src); + this.applySrc(this.data.src, this.data.token, this.data.contentType); }, loadTemplates() { @@ -338,7 +341,7 @@ AFRAME.registerComponent("gltf-model-plus", { }); }, - async applySrc(src) { + async applySrc(src, token, contentType) { try { // If the src attribute is a selector, get the url from the asset item. if (src && src.charAt(0) === "#") { @@ -360,7 +363,7 @@ AFRAME.registerComponent("gltf-model-plus", { const gltfPath = THREE.LoaderUtils.extractUrlBase(src); if (!GLTFCache[src]) { - GLTFCache[src] = loadGLTF(src, this.preferredTechnique); + GLTFCache[src] = loadGLTF(src, token, contentType, this.preferredTechnique); } const model = cloneGltf(await GLTFCache[src]); diff --git a/src/components/image-plus.js b/src/components/image-plus.js index 43e32b9a8794f91c56faafc2688b71268fc50acf..fb5ae6941a9afe00fa90d23450673bbc618dd85e 100644 --- a/src/components/image-plus.js +++ b/src/components/image-plus.js @@ -1,5 +1,6 @@ import GIFWorker from "../workers/gifparsing.worker.js"; import errorImageSrc from "!!url-loader!../assets/images/media-error.gif"; +import { resolveMedia } from "../utils/media-utils"; class GIFTexture extends THREE.Texture { constructor(frames, delays, disposals) { @@ -69,6 +70,7 @@ errorImage.onload = () => { AFRAME.registerComponent("image-plus", { schema: { src: { type: "string" }, + token: { type: "string" }, contentType: { type: "string" }, depth: { default: 0.05 } @@ -177,7 +179,8 @@ AFRAME.registerComponent("image-plus", { let texture; try { const url = this.data.src; - const contentType = this.data.contentType; + const token = this.data.token; + let contentType = this.data.contentType; if (!url) { return; } @@ -188,15 +191,21 @@ AFRAME.registerComponent("image-plus", { texture = cacheItem.texture; cacheItem.count++; } else { + const resolved = await resolveMedia(url, token); + const { raw } = resolved; + if (!contentType) { + contentType = resolved.contentType; + } + cacheItem = { count: 1 }; - if (url === "error") { + if (raw === "error") { texture = errorTexture; - } else if (contentType === "image/gif") { - texture = await this.loadGIF(url); + } else if (contentType.includes("image/gif")) { + texture = await this.loadGIF(raw); } else if (contentType.startsWith("image/")) { - texture = await this.loadImage(url); + texture = await this.loadImage(raw); } else if (contentType.startsWith("video/") || contentType.startsWith("audio/")) { - texture = await this.loadVideo(url); + texture = await this.loadVideo(raw); cacheItem.audioSource = this.el.sceneEl.audioListener.context.createMediaElementSource(texture.image); } else { throw new Error(`Unknown content type: ${contentType}`); diff --git a/src/components/media-loader.js b/src/components/media-loader.js index edfc40bc67dbff42b6cc22af5753cfd5daaa10e9..81303a07d67946e187ea7664252dab3af20846b3 100644 --- a/src/components/media-loader.js +++ b/src/components/media-loader.js @@ -4,11 +4,20 @@ import { resolveMedia } from "../utils/media-utils"; AFRAME.registerComponent("media-loader", { schema: { src: { type: "string" }, + token: { type: "string" }, resize: { default: false } }, init() { this.onError = this.onError.bind(this); + this.showLoader = this.showLoader.bind(this); + }, + + remove() { + if (this.blobURL) { + URL.revokeObjectURL(this.blobURL); + this.blobURL = null; + } }, setShapeAndScale(resize) { @@ -40,18 +49,36 @@ AFRAME.registerComponent("media-loader", { clearTimeout(this.showLoaderTimeout); }, + showLoader() { + const loadingObj = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial()); + this.el.setObject3D("mesh", loadingObj); + this.setShapeAndScale(true); + }, + // TODO: correctly handle case where src changes async update() { try { const url = this.data.src; + const token = this.data.token; - this.showLoaderTimeout = setTimeout(() => { - const loadingObj = new THREE.Mesh(new THREE.BoxGeometry(), new THREE.MeshBasicMaterial()); - this.el.setObject3D("mesh", loadingObj); - this.setShapeAndScale(true); - }, 100); + this.showLoaderTimeout = this.showLoaderTimeout || setTimeout(this.showLoader, 100); - const { raw, contentType } = await resolveMedia(url); + if (!url) return; + + const { raw, origin, contentType } = await resolveMedia(url, token); + + if (token) { + if (this.blobURL) { + URL.revokeObjectURL(this.blobURL); + this.blobURL = null; + } + const response = await fetch(raw, { + method: "GET", + headers: { Authorization: `Token ${token}` } + }); + const blob = await response.blob(); + this.blobURL = window.URL.createObjectURL(blob); + } if (contentType.startsWith("image/") || contentType.startsWith("video/") || contentType.startsWith("audio/")) { this.el.addEventListener( @@ -61,9 +88,15 @@ AFRAME.registerComponent("media-loader", { }, { once: true } ); - this.el.setAttribute("image-plus", { src: raw, contentType }); + this.el.setAttribute("image-plus", { src: this.blobURL || raw, contentType, token }); this.el.setAttribute("position-at-box-shape-border", { target: ".delete-button", dirs: ["forward", "back"] }); - } else if (contentType.startsWith("model/gltf") || url.endsWith(".gltf") || url.endsWith(".glb")) { + } else if ( + contentType.includes("application/octet-stream") || + contentType.includes("x-zip-compressed") || + contentType.startsWith("model/gltf") || + url.endsWith(".gltf") || + url.endsWith(".glb") + ) { this.el.addEventListener( "model-loaded", () => { @@ -73,8 +106,10 @@ AFRAME.registerComponent("media-loader", { { once: true } ); this.el.addEventListener("model-error", this.onError, { once: true }); + const src = this.blobURL || origin || url; this.el.setAttribute("gltf-model-plus", { - src: url, // gltf-model-plus expects the unresolved gltf url. The resolved farspark URL will be retrieved from the cache. + src, + contentType, inflate: true }); } else { diff --git a/src/hub.js b/src/hub.js index 6af7d7750f56f9d37718ab90b0378ef6808fdf08..db26ba4d3fea99f82f7616c663b5ec2486f757e9 100644 --- a/src/hub.js +++ b/src/hub.js @@ -297,8 +297,8 @@ const onReady = async () => { }); const offset = { x: 0, y: 0, z: -1.5 }; - const spawnMediaInfrontOfPlayer = url => { - const entity = addMedia(url, true); + const spawnMediaInfrontOfPlayer = src => { + const entity = addMedia(src, true); entity.setAttribute("offset-relative-to", { target: "#player-camera", offset @@ -313,9 +313,15 @@ const onReady = async () => { document.addEventListener("paste", e => { if (e.target.nodeName === "INPUT") return; - const imgUrl = e.clipboardData.getData("text"); - console.log("Pasted: ", imgUrl, e); - spawnMediaInfrontOfPlayer(imgUrl); + const url = e.clipboardData.getData("text"); + const files = e.clipboardData.files && e.clipboardData.files; + if (url) { + spawnMediaInfrontOfPlayer(url); + } else { + for (const file of files) { + spawnMediaInfrontOfPlayer(file); + } + } }); document.addEventListener("dragover", e => { @@ -324,10 +330,14 @@ const onReady = async () => { document.addEventListener("drop", e => { e.preventDefault(); - const imgUrl = e.dataTransfer.getData("url"); - if (imgUrl) { - console.log("Dropped: ", imgUrl); - spawnMediaInfrontOfPlayer(imgUrl); + const url = e.dataTransfer.getData("url"); + const files = e.dataTransfer.files; + if (url) { + spawnMediaInfrontOfPlayer(url); + } else { + for (const file of files) { + spawnMediaInfrontOfPlayer(file); + } } }); } diff --git a/src/react-components/create-object-dialog.js b/src/react-components/create-object-dialog.js index f450f6f478e0907e8ebebcbde204be52260c5b1b..16dfa5c1be3e91bca26d63185d7cc6d7e52c33e7 100644 --- a/src/react-components/create-object-dialog.js +++ b/src/react-components/create-object-dialog.js @@ -1,19 +1,37 @@ import React, { Component } from "react"; +import "aframe"; import PropTypes from "prop-types"; - import giphyLogo from "../assets/images/giphy_logo.png"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faPaperclip, faTimes } from "@fortawesome/free-solid-svg-icons"; +import styles from "../assets/stylesheets/create-object-dialog.scss"; +import cx from "classnames"; const attributionHostnames = { - "giphy.com": giphyLogo + "giphy.com": giphyLogo, + "media.giphy.com": giphyLogo }; const DEFAULT_OBJECT_URL = "https://asset-bundles-prod.reticulum.io/interactables/Ducky/DuckyMesh-438ff8e022.gltf"; +const isMobile = AFRAME.utils.device.isMobile(); +const instructions = "Paste a URL or upload a file."; +const desktopTips = "Tip: You can paste links directly into Hubs with Ctrl+V"; +const mobileInstructions = <div>{instructions}</div>; +const desktopInstructions = ( + <div> + <p>{instructions}</p> + <p>{desktopTips}</p> + </div> +); let lastUrl = ""; +const fileInputId = "file-input"; export default class CreateObjectDialog extends Component { state = { - url: "" + url: "", + file: null, + fileName: "" }; static propTypes = { @@ -22,9 +40,7 @@ export default class CreateObjectDialog extends Component { }; componentDidMount() { - this.setState({ url: lastUrl }, () => { - this.onUrlChange({ target: this.input }); - }); + this.setState({ url: lastUrl }); } componentWillUnmount() { @@ -32,44 +48,79 @@ export default class CreateObjectDialog extends Component { } onUrlChange = e => { - if (e && e.target.value && e.target.value !== "") { - this.setState({ - url: e.target.value, - attributionImage: e.target.validity.valid && attributionHostnames[new URL(e.target.value).hostname] - }); + let attributionImage = this.state.attributionImage; + if (e.target && e.target.value && e.target.validity.valid) { + attributionImage = attributionHostnames[new URL(e.target.value).hostname]; } + this.setState({ + url: e.target && e.target.value, + attributionImage: attributionImage + }); + }; + + onFileChange = e => { + this.setState({ + file: e.target.files[0], + fileName: e.target.files[0].name + }); }; - onCreateClicked = () => { - this.props.onCreateObject(this.state.url || DEFAULT_OBJECT_URL); + onCreateClicked = e => { + e.preventDefault(); + this.props.onCreateObject(this.state.file || this.state.url || DEFAULT_OBJECT_URL); this.props.onCloseDialog(); }; + reset = e => { + e.preventDefault(); + this.setState({ + url: "", + file: null, + fileName: "" + }); + this.fileInput.value = null; + }; + render() { + const cancelButton = ( + <label className={cx(styles.smallButton, styles.cancelIcon)} onClick={this.reset}> + <FontAwesomeIcon icon={faTimes} /> + </label> + ); + const uploadButton = ( + <label htmlFor={fileInputId} className={cx(styles.smallButton, styles.uploadIcon)}> + <FontAwesomeIcon icon={faPaperclip} /> + </label> + ); + const filenameLabel = <label className={cx(styles.leftSideOfInput)}>{this.state.fileName}</label>; + const urlInput = ( + <input + className={cx(styles.leftSideOfInput)} + placeholder="Image/Video/glTF URL" + onChange={this.onUrlChange} + type="url" + value={this.state.url} + /> + ); + return ( <div> - {!AFRAME.utils.device.isMobile() ? ( - <div> - Paste a URL from the web to create an object in the room. - <br /> - Tip: You can paste directly into Hubs using Ctrl+V - </div> - ) : ( - <div>Paste a URL from the web to create an object in the room.</div> - )} - + {isMobile ? mobileInstructions : desktopInstructions} <form onSubmit={this.onCreateClicked}> - <div className="add-media-form"> + <div className={styles.addMediaForm}> <input - ref={el => (this.input = el)} - type="url" - placeholder="Image, Video, or GLTF URL" - className="add-media-form__link_field" - value={this.state.url} - onChange={this.onUrlChange} + id={fileInputId} + ref={f => (this.fileInput = f)} + className={styles.hideFileInput} + type="file" + onChange={this.onFileChange} /> - <div className="add-media-form__buttons"> - <button className="add-media-form__action-button"> + <div className={styles.inputBorder}> + {this.state.file ? filenameLabel : urlInput} + {this.state.url || this.state.fileName ? cancelButton : uploadButton} + </div> + <div className={styles.buttons}> + <button className={styles.actionButton}> <span>create</span> </button> </div> diff --git a/src/utils/media-utils.js b/src/utils/media-utils.js index 0ebd5c3e1af6cc3e24152fe16fea8835faa22542..dc7d5773085acee914e3b70d894d0b14adfe221b 100644 --- a/src/utils/media-utils.js +++ b/src/utils/media-utils.js @@ -6,15 +6,24 @@ if (process.env.RETICULUM_SERVER) { mediaAPIEndpoint = `https://${process.env.RETICULUM_SERVER}${mediaAPIEndpoint}`; } -const fetchContentType = async url => fetch(url, { method: "HEAD" }).then(r => r.headers.get("content-type")); +const fetchContentType = async (url, token) => { + const args = { method: "HEAD" }; + + if (token) { + args.headers = { Authorization: `Token ${token}` }; + } + + return fetch(url, args).then(r => r.headers.get("content-type")); +}; const resolveMediaCache = new Map(); -export const resolveMedia = async url => { +export const resolveMedia = async (url, token, skipContentType) => { const parsedUrl = new URL(url); if (resolveMediaCache.has(url)) return resolveMediaCache.get(url); + const isNotHttpOrHttps = parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:"; const resolved = - (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") || isHostWhitelisted(parsedUrl.hostname) + isNotHttpOrHttps || isHostWhitelisted(parsedUrl.hostname) ? { raw: url, origin: url } : await fetch(mediaAPIEndpoint, { method: "POST", @@ -22,13 +31,25 @@ export const resolveMedia = async url => { body: JSON.stringify({ media: { url } }) }).then(r => r.json()); - const contentType = (resolved.meta && resolved.meta.expected_content_type) || (await fetchContentType(resolved.raw)); - resolved.contentType = contentType; + if (!isNotHttpOrHttps && !skipContentType) { + const contentType = + (resolved.meta && resolved.meta.expected_content_type) || (await fetchContentType(resolved.raw, token)); + resolved.contentType = contentType; + } resolveMediaCache.set(url, resolved); return resolved; }; +export const upload = file => { + const formData = new FormData(); + formData.append("media", file); + return fetch(mediaAPIEndpoint, { + method: "POST", + body: formData + }).then(r => r.json()); +}; + let interactableId = 0; export const addMedia = (src, resize = false) => { const scene = AFRAME.scenes[0]; @@ -36,7 +57,19 @@ export const addMedia = (src, resize = false) => { const entity = document.createElement("a-entity"); entity.id = "interactable-media-" + interactableId++; entity.setAttribute("networked", { template: "#interactable-media" }); - entity.setAttribute("media-loader", { src, resize }); + entity.setAttribute("media-loader", { resize, src: typeof src === "string" ? src : "" }); scene.appendChild(entity); + + if (src instanceof File) { + upload(src) + .then(response => { + const src = response.raw; + const token = response.meta.access_token; + entity.setAttribute("media-loader", { src, token }); + }) + .catch(() => { + entity.setAttribute("media-loader", { src: "error" }); + }); + } return entity; }; diff --git a/webpack.config.js b/webpack.config.js index b5c8473df935345b390cabd651fb6fc2b65b28c0..b8f961ac443a3ca8ffaade25464829751ab857ce 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -178,7 +178,7 @@ module.exports = (env, argv) => ({ new HTMLWebpackPlugin({ filename: "index.html", template: path.join(__dirname, "src", "index.html"), - chunks: ["vendor", "index"] + chunks: ["vendor", "engine", "index"] }), new HTMLWebpackPlugin({ filename: "hub.html",