this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 735 lines 20 kB view raw
1import { 2 AmbientLight, 3 AnimationMixer, 4 AxesHelper, 5 Box3, 6 Cache, 7 Color, 8 DirectionalLight, 9 GridHelper, 10 HemisphereLight, 11 LoaderUtils, 12 LoadingManager, 13 PMREMGenerator, 14 PerspectiveCamera, 15 PointsMaterial, 16 REVISION, 17 Scene, 18 SkeletonHelper, 19 Vector3, 20 WebGLRenderer, 21 LinearToneMapping, 22 ACESFilmicToneMapping, 23} from 'three'; 24import Stats from 'three/addons/libs/stats.module.js'; 25import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; 26import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js'; 27import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'; 28import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js'; 29import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; 30import { EXRLoader } from 'three/addons/loaders/EXRLoader.js'; 31import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js'; 32 33import { GUI } from 'dat.gui'; 34 35import { environments } from './environments.js'; 36 37const DEFAULT_CAMERA = '[default]'; 38 39const MANAGER = new LoadingManager(); 40const THREE_PATH = `https://unpkg.com/three@0.${REVISION}.x`; 41const DRACO_LOADER = new DRACOLoader(MANAGER).setDecoderPath( 42 `${THREE_PATH}/examples/jsm/libs/draco/gltf/`, 43); 44const KTX2_LOADER = new KTX2Loader(MANAGER).setTranscoderPath( 45 `${THREE_PATH}/examples/jsm/libs/basis/`, 46); 47 48const IS_IOS = isIOS(); 49 50const Preset = { ASSET_GENERATOR: 'assetgenerator' }; 51 52Cache.enabled = true; 53 54export class Viewer { 55 constructor(el, options) { 56 this.el = el; 57 this.options = options; 58 59 this.lights = []; 60 this.content = null; 61 this.mixer = null; 62 this.clips = []; 63 this.gui = null; 64 65 this.state = { 66 environment: 67 options.preset === Preset.ASSET_GENERATOR 68 ? environments.find((e) => e.id === 'footprint-court').name 69 : environments[1].name, 70 background: false, 71 playbackSpeed: 1.0, 72 actionStates: {}, 73 animationPaused: false, 74 camera: DEFAULT_CAMERA, 75 wireframe: false, 76 skeleton: false, 77 grid: false, 78 autoRotate: false, 79 80 // Lights 81 punctualLights: true, 82 exposure: 0.0, 83 toneMapping: LinearToneMapping, 84 ambientIntensity: 0.3, 85 ambientColor: '#FFFFFF', 86 directIntensity: 0.8 * Math.PI, // TODO(#116) 87 directColor: '#FFFFFF', 88 bgColor: '#191919', 89 90 pointSize: 1.0, 91 }; 92 93 this.prevTime = 0; 94 95 this.stats = new Stats(); 96 this.stats.dom.height = '48px'; 97 [].forEach.call(this.stats.dom.children, (child) => (child.style.display = '')); 98 99 this.backgroundColor = new Color(this.state.bgColor); 100 101 this.scene = new Scene(); 102 this.scene.background = this.backgroundColor; 103 104 const fov = options.preset === Preset.ASSET_GENERATOR ? (0.8 * 180) / Math.PI : 60; 105 const aspect = el.clientWidth / el.clientHeight; 106 this.defaultCamera = new PerspectiveCamera(fov, aspect, 0.01, 1000); 107 this.activeCamera = this.defaultCamera; 108 this.scene.add(this.defaultCamera); 109 110 this.renderer = window.renderer = new WebGLRenderer({ antialias: true }); 111 this.renderer.setClearColor(0xcccccc); 112 this.renderer.setPixelRatio(window.devicePixelRatio); 113 this.renderer.setSize(el.clientWidth, el.clientHeight); 114 115 this.pmremGenerator = new PMREMGenerator(this.renderer); 116 this.pmremGenerator.compileEquirectangularShader(); 117 118 this.neutralEnvironment = this.pmremGenerator.fromScene(new RoomEnvironment()).texture; 119 120 this.controls = new OrbitControls(this.defaultCamera, this.renderer.domElement); 121 this.controls.screenSpacePanning = true; 122 123 this.el.appendChild(this.renderer.domElement); 124 125 this.cameraCtrl = null; 126 this.cameraFolder = null; 127 this.animFolder = null; 128 this.animCtrls = []; 129 this.morphFolder = null; 130 this.morphCtrls = []; 131 this.skeletonHelpers = []; 132 this.gridHelper = null; 133 this.axesHelper = null; 134 135 this.addAxesHelper(); 136 this.addGUI(); 137 if (options.kiosk) this.gui.close(); 138 139 this.animate = this.animate.bind(this); 140 requestAnimationFrame(this.animate); 141 window.addEventListener('resize', this.resize.bind(this), false); 142 } 143 144 animate(time) { 145 requestAnimationFrame(this.animate); 146 147 const dt = (time - this.prevTime) / 1000; 148 149 this.controls.update(); 150 this.stats.update(); 151 this.mixer && this.mixer.update(dt); 152 this.render(); 153 154 this.prevTime = time; 155 } 156 157 render() { 158 this.renderer.render(this.scene, this.activeCamera); 159 if (this.state.grid) { 160 this.axesCamera.position.copy(this.defaultCamera.position); 161 this.axesCamera.lookAt(this.axesScene.position); 162 this.axesRenderer.render(this.axesScene, this.axesCamera); 163 } 164 } 165 166 resize() { 167 const { clientHeight, clientWidth } = this.el.parentElement; 168 169 this.defaultCamera.aspect = clientWidth / clientHeight; 170 this.defaultCamera.updateProjectionMatrix(); 171 this.renderer.setSize(clientWidth, clientHeight); 172 173 this.axesCamera.aspect = this.axesDiv.clientWidth / this.axesDiv.clientHeight; 174 this.axesCamera.updateProjectionMatrix(); 175 this.axesRenderer.setSize(this.axesDiv.clientWidth, this.axesDiv.clientHeight); 176 } 177 178 load(url, rootPath, assetMap) { 179 const baseURL = LoaderUtils.extractUrlBase(url); 180 181 // Load. 182 return new Promise((resolve, reject) => { 183 // Intercept and override relative URLs. 184 MANAGER.setURLModifier((url, path) => { 185 // URIs in a glTF file may be escaped, or not. Assume that assetMap is 186 // from an un-escaped source, and decode all URIs before lookups. 187 // See: https://github.com/donmccurdy/three-gltf-viewer/issues/146 188 const normalizedURL = 189 rootPath + 190 decodeURI(url) 191 .replace(baseURL, '') 192 .replace(/^(\.?\/)/, ''); 193 194 if (assetMap.has(normalizedURL)) { 195 const blob = assetMap.get(normalizedURL); 196 const blobURL = URL.createObjectURL(blob); 197 blobURLs.push(blobURL); 198 return blobURL; 199 } 200 201 return (path || '') + url; 202 }); 203 204 const loader = new GLTFLoader(MANAGER) 205 .setCrossOrigin('anonymous') 206 .setDRACOLoader(DRACO_LOADER) 207 .setKTX2Loader(KTX2_LOADER.detectSupport(this.renderer)) 208 .setMeshoptDecoder(MeshoptDecoder); 209 210 const blobURLs = []; 211 212 loader.load( 213 url, 214 (gltf) => { 215 window.VIEWER.json = gltf; 216 217 const scene = gltf.scene || gltf.scenes[0]; 218 const clips = gltf.animations || []; 219 220 if (!scene) { 221 // Valid, but not supported by this viewer. 222 throw new Error( 223 'This model contains no scene, and cannot be viewed here. However,' + 224 ' it may contain individual 3D resources.', 225 ); 226 } 227 228 this.setContent(scene, clips); 229 230 blobURLs.forEach(URL.revokeObjectURL); 231 232 // See: https://github.com/google/draco/issues/349 233 // DRACOLoader.releaseDecoderModule(); 234 235 resolve(gltf); 236 }, 237 undefined, 238 reject, 239 ); 240 }); 241 } 242 243 /** 244 * @param {THREE.Object3D} object 245 * @param {Array<THREE.AnimationClip} clips 246 */ 247 setContent(object, clips) { 248 this.clear(); 249 250 object.updateMatrixWorld(); // donmccurdy/three-gltf-viewer#330 251 252 const box = new Box3().setFromObject(object); 253 const size = box.getSize(new Vector3()).length(); 254 const center = box.getCenter(new Vector3()); 255 256 this.controls.reset(); 257 258 object.position.x -= center.x; 259 object.position.y -= center.y; 260 object.position.z -= center.z; 261 262 this.controls.maxDistance = size * 10; 263 264 this.defaultCamera.near = size / 100; 265 this.defaultCamera.far = size * 100; 266 this.defaultCamera.updateProjectionMatrix(); 267 268 if (this.options.cameraPosition) { 269 this.defaultCamera.position.fromArray(this.options.cameraPosition); 270 this.defaultCamera.lookAt(new Vector3()); 271 } else { 272 this.defaultCamera.position.copy(center); 273 this.defaultCamera.position.x += size / 2.0; 274 this.defaultCamera.position.y += size / 5.0; 275 this.defaultCamera.position.z += size / 2.0; 276 this.defaultCamera.lookAt(center); 277 } 278 279 this.setCamera(DEFAULT_CAMERA); 280 281 this.axesCamera.position.copy(this.defaultCamera.position); 282 this.axesCamera.lookAt(this.axesScene.position); 283 this.axesCamera.near = size / 100; 284 this.axesCamera.far = size * 100; 285 this.axesCamera.updateProjectionMatrix(); 286 this.axesCorner.scale.set(size, size, size); 287 288 this.controls.saveState(); 289 290 this.scene.add(object); 291 this.content = object; 292 293 this.state.punctualLights = true; 294 295 this.content.traverse((node) => { 296 if (node.isLight) { 297 this.state.punctualLights = false; 298 } 299 }); 300 301 this.setClips(clips); 302 303 this.updateLights(); 304 this.updateGUI(); 305 this.updateEnvironment(); 306 this.updateDisplay(); 307 308 window.VIEWER.scene = this.content; 309 310 this.printGraph(this.content); 311 } 312 313 printGraph(node) { 314 console.group(' <' + node.type + '> ' + node.name); 315 node.children.forEach((child) => this.printGraph(child)); 316 console.groupEnd(); 317 } 318 319 /** 320 * @param {Array<THREE.AnimationClip} clips 321 */ 322 setClips(clips) { 323 if (this.mixer) { 324 this.mixer.stopAllAction(); 325 this.mixer.uncacheRoot(this.mixer.getRoot()); 326 this.mixer = null; 327 } 328 329 this.state.animationPaused = false; 330 331 this.clips = clips; 332 if (!clips.length) return; 333 334 this.mixer = new AnimationMixer(this.content); 335 } 336 337 playAllClips() { 338 this.clips.forEach((clip) => { 339 this.mixer.clipAction(clip).reset().play(); 340 this.state.actionStates[clip.name] = true; 341 }); 342 } 343 344 toggleAnimation(paused) { 345 if (!this.mixer) return; 346 347 if (paused) { 348 this.mixer.timeScale = 0; 349 } else { 350 this.mixer.timeScale = this.state.playbackSpeed; 351 } 352 } 353 354 /** 355 * @param {string} name 356 */ 357 setCamera(name) { 358 if (name === DEFAULT_CAMERA) { 359 this.controls.enabled = true; 360 this.activeCamera = this.defaultCamera; 361 } else { 362 this.controls.enabled = false; 363 this.content.traverse((node) => { 364 if (node.isCamera && node.name === name) { 365 this.activeCamera = node; 366 } 367 }); 368 } 369 } 370 371 updateLights() { 372 const state = this.state; 373 const lights = this.lights; 374 375 if (state.punctualLights && !lights.length) { 376 this.addLights(); 377 } else if (!state.punctualLights && lights.length) { 378 this.removeLights(); 379 } 380 381 this.renderer.toneMapping = Number(state.toneMapping); 382 this.renderer.toneMappingExposure = Math.pow(2, state.exposure); 383 384 if (lights.length === 2) { 385 lights[0].intensity = state.ambientIntensity; 386 lights[0].color.set(state.ambientColor); 387 lights[1].intensity = state.directIntensity; 388 lights[1].color.set(state.directColor); 389 } 390 } 391 392 addLights() { 393 const state = this.state; 394 395 if (this.options.preset === Preset.ASSET_GENERATOR) { 396 const hemiLight = new HemisphereLight(); 397 hemiLight.name = 'hemi_light'; 398 this.scene.add(hemiLight); 399 this.lights.push(hemiLight); 400 return; 401 } 402 403 const light1 = new AmbientLight(state.ambientColor, state.ambientIntensity); 404 light1.name = 'ambient_light'; 405 this.defaultCamera.add(light1); 406 407 const light2 = new DirectionalLight(state.directColor, state.directIntensity); 408 light2.position.set(0.5, 0, 0.866); // ~60º 409 light2.name = 'main_light'; 410 this.defaultCamera.add(light2); 411 412 this.lights.push(light1, light2); 413 } 414 415 removeLights() { 416 this.lights.forEach((light) => light.parent.remove(light)); 417 this.lights.length = 0; 418 } 419 420 updateEnvironment() { 421 const environment = environments.filter( 422 (entry) => entry.name === this.state.environment, 423 )[0]; 424 425 this.getCubeMapTexture(environment).then(({ envMap }) => { 426 this.scene.environment = envMap; 427 this.scene.background = this.state.background ? envMap : this.backgroundColor; 428 }); 429 } 430 431 getCubeMapTexture(environment) { 432 const { id, path } = environment; 433 434 // neutral (THREE.RoomEnvironment) 435 if (id === 'neutral') { 436 return Promise.resolve({ envMap: this.neutralEnvironment }); 437 } 438 439 // none 440 if (id === '') { 441 return Promise.resolve({ envMap: null }); 442 } 443 444 return new Promise((resolve, reject) => { 445 new EXRLoader().load( 446 path, 447 (texture) => { 448 const envMap = this.pmremGenerator.fromEquirectangular(texture).texture; 449 this.pmremGenerator.dispose(); 450 451 resolve({ envMap }); 452 }, 453 undefined, 454 reject, 455 ); 456 }); 457 } 458 459 updateDisplay() { 460 if (this.skeletonHelpers.length) { 461 this.skeletonHelpers.forEach((helper) => this.scene.remove(helper)); 462 } 463 464 traverseMaterials(this.content, (material) => { 465 material.wireframe = this.state.wireframe; 466 467 if (material instanceof PointsMaterial) { 468 material.size = this.state.pointSize; 469 } 470 }); 471 472 this.content.traverse((node) => { 473 if (node.geometry && node.skeleton && this.state.skeleton) { 474 const helper = new SkeletonHelper(node.skeleton.bones[0].parent); 475 helper.material.linewidth = 3; 476 this.scene.add(helper); 477 this.skeletonHelpers.push(helper); 478 } 479 }); 480 481 if (this.state.grid !== Boolean(this.gridHelper)) { 482 if (this.state.grid) { 483 this.gridHelper = new GridHelper(); 484 this.axesHelper = new AxesHelper(); 485 this.axesHelper.renderOrder = 999; 486 this.axesHelper.onBeforeRender = (renderer) => renderer.clearDepth(); 487 this.scene.add(this.gridHelper); 488 this.scene.add(this.axesHelper); 489 } else { 490 this.scene.remove(this.gridHelper); 491 this.scene.remove(this.axesHelper); 492 this.gridHelper = null; 493 this.axesHelper = null; 494 this.axesRenderer.clear(); 495 } 496 } 497 498 this.controls.autoRotate = this.state.autoRotate; 499 } 500 501 updateBackground() { 502 this.backgroundColor.set(this.state.bgColor); 503 } 504 505 /** 506 * Adds AxesHelper. 507 * 508 * See: https://stackoverflow.com/q/16226693/1314762 509 */ 510 addAxesHelper() { 511 this.axesDiv = document.createElement('div'); 512 this.el.appendChild(this.axesDiv); 513 this.axesDiv.classList.add('axes'); 514 515 const { clientWidth, clientHeight } = this.axesDiv; 516 517 this.axesScene = new Scene(); 518 this.axesCamera = new PerspectiveCamera(50, clientWidth / clientHeight, 0.1, 10); 519 this.axesScene.add(this.axesCamera); 520 521 this.axesRenderer = new WebGLRenderer({ alpha: true }); 522 this.axesRenderer.setPixelRatio(window.devicePixelRatio); 523 this.axesRenderer.setSize(this.axesDiv.clientWidth, this.axesDiv.clientHeight); 524 525 this.axesCamera.up = this.defaultCamera.up; 526 527 this.axesCorner = new AxesHelper(5); 528 this.axesScene.add(this.axesCorner); 529 this.axesDiv.appendChild(this.axesRenderer.domElement); 530 } 531 532 addGUI() { 533 const gui = (this.gui = new GUI({ 534 autoPlace: false, 535 width: 260, 536 hideable: true, 537 })); 538 539 // Display controls. 540 const dispFolder = gui.addFolder('Display'); 541 const envBackgroundCtrl = dispFolder.add(this.state, 'background'); 542 envBackgroundCtrl.onChange(() => this.updateEnvironment()); 543 const autoRotateCtrl = dispFolder.add(this.state, 'autoRotate'); 544 autoRotateCtrl.onChange(() => this.updateDisplay()); 545 const wireframeCtrl = dispFolder.add(this.state, 'wireframe'); 546 wireframeCtrl.onChange(() => this.updateDisplay()); 547 const skeletonCtrl = dispFolder.add(this.state, 'skeleton'); 548 skeletonCtrl.onChange(() => this.updateDisplay()); 549 const gridCtrl = dispFolder.add(this.state, 'grid'); 550 gridCtrl.onChange(() => this.updateDisplay()); 551 dispFolder.add(this.controls, 'screenSpacePanning'); 552 const pointSizeCtrl = dispFolder.add(this.state, 'pointSize', 1, 16); 553 pointSizeCtrl.onChange(() => this.updateDisplay()); 554 const bgColorCtrl = dispFolder.addColor(this.state, 'bgColor'); 555 bgColorCtrl.onChange(() => this.updateBackground()); 556 557 // Lighting controls. 558 const lightFolder = gui.addFolder('Lighting'); 559 const envMapCtrl = lightFolder.add( 560 this.state, 561 'environment', 562 environments.map((env) => env.name), 563 ); 564 envMapCtrl.onChange(() => this.updateEnvironment()); 565 [ 566 lightFolder.add(this.state, 'toneMapping', { 567 Linear: LinearToneMapping, 568 'ACES Filmic': ACESFilmicToneMapping, 569 }), 570 lightFolder.add(this.state, 'exposure', -10, 10, 0.01), 571 lightFolder.add(this.state, 'punctualLights').listen(), 572 lightFolder.add(this.state, 'ambientIntensity', 0, 2), 573 lightFolder.addColor(this.state, 'ambientColor'), 574 lightFolder.add(this.state, 'directIntensity', 0, 4), // TODO(#116) 575 lightFolder.addColor(this.state, 'directColor'), 576 ].forEach((ctrl) => ctrl.onChange(() => this.updateLights())); 577 578 // Animation controls. 579 this.animFolder = gui.addFolder('Animation'); 580 this.animFolder.domElement.style.display = 'none'; 581 const playbackSpeedCtrl = this.animFolder.add(this.state, 'playbackSpeed', 0, 1); 582 playbackSpeedCtrl.onChange((speed) => { 583 if (this.mixer) this.mixer.timeScale = speed; 584 }); 585 this.animFolder.add({ playAll: () => this.playAllClips() }, 'playAll'); 586 587 const animationToggleCtrl = this.animFolder.add(this.state, 'animationPaused'); 588 animationToggleCtrl.onChange((paused) => this.toggleAnimation(paused)); 589 animationToggleCtrl.name('animationOff'); 590 591 // Morph target controls. 592 this.morphFolder = gui.addFolder('Morph Targets'); 593 this.morphFolder.domElement.style.display = 'none'; 594 595 // Camera controls. 596 this.cameraFolder = gui.addFolder('Cameras'); 597 this.cameraFolder.domElement.style.display = 'none'; 598 599 // Stats. 600 const perfFolder = gui.addFolder('Performance'); 601 const perfLi = document.createElement('li'); 602 this.stats.dom.style.position = 'static'; 603 perfLi.appendChild(this.stats.dom); 604 perfLi.classList.add('gui-stats'); 605 perfFolder.__ul.appendChild(perfLi); 606 607 const guiWrap = document.createElement('div'); 608 this.el.appendChild(guiWrap); 609 guiWrap.classList.add('gui-wrap'); 610 guiWrap.appendChild(gui.domElement); 611 gui.open(); 612 } 613 614 updateGUI() { 615 this.cameraFolder.domElement.style.display = 'none'; 616 617 this.morphCtrls.forEach((ctrl) => ctrl.remove()); 618 this.morphCtrls.length = 0; 619 this.morphFolder.domElement.style.display = 'none'; 620 621 this.animCtrls.forEach((ctrl) => ctrl.remove()); 622 this.animCtrls.length = 0; 623 this.animFolder.domElement.style.display = 'none'; 624 625 const cameraNames = []; 626 const morphMeshes = []; 627 this.content.traverse((node) => { 628 if (node.geometry && node.morphTargetInfluences) { 629 morphMeshes.push(node); 630 } 631 if (node.isCamera) { 632 node.name = node.name || `VIEWER__camera_${cameraNames.length + 1}`; 633 cameraNames.push(node.name); 634 } 635 }); 636 637 if (cameraNames.length) { 638 this.cameraFolder.domElement.style.display = ''; 639 if (this.cameraCtrl) this.cameraCtrl.remove(); 640 const cameraOptions = [DEFAULT_CAMERA].concat(cameraNames); 641 this.cameraCtrl = this.cameraFolder.add(this.state, 'camera', cameraOptions); 642 this.cameraCtrl.onChange((name) => this.setCamera(name)); 643 } 644 645 if (morphMeshes.length) { 646 this.morphFolder.domElement.style.display = ''; 647 morphMeshes.forEach((mesh) => { 648 if (mesh.morphTargetInfluences.length) { 649 const nameCtrl = this.morphFolder.add( 650 { name: mesh.name || 'Untitled' }, 651 'name', 652 ); 653 this.morphCtrls.push(nameCtrl); 654 } 655 for (let i = 0; i < mesh.morphTargetInfluences.length; i++) { 656 const ctrl = this.morphFolder 657 .add(mesh.morphTargetInfluences, i, 0, 1, 0.01) 658 .listen(); 659 Object.keys(mesh.morphTargetDictionary).forEach((key) => { 660 if (key && mesh.morphTargetDictionary[key] === i) ctrl.name(key); 661 }); 662 this.morphCtrls.push(ctrl); 663 } 664 }); 665 } 666 667 if (this.clips.length) { 668 this.animFolder.domElement.style.display = ''; 669 const actionStates = (this.state.actionStates = {}); 670 this.clips.forEach((clip, clipIndex) => { 671 clip.name = `${clipIndex + 1}. ${clip.name}`; 672 673 // Autoplay the first clip. 674 let action; 675 if (clipIndex === 0) { 676 actionStates[clip.name] = true; 677 action = this.mixer.clipAction(clip); 678 action.play(); 679 } else { 680 actionStates[clip.name] = false; 681 } 682 683 // Play other clips when enabled. 684 const ctrl = this.animFolder.add(actionStates, clip.name).listen(); 685 ctrl.onChange((playAnimation) => { 686 action = action || this.mixer.clipAction(clip); 687 action.setEffectiveTimeScale(1); 688 playAnimation ? action.play() : action.stop(); 689 }); 690 this.animCtrls.push(ctrl); 691 }); 692 } 693 } 694 695 clear() { 696 if (!this.content) return; 697 698 this.scene.remove(this.content); 699 700 // dispose geometry 701 this.content.traverse((node) => { 702 if (!node.geometry) return; 703 704 node.geometry.dispose(); 705 }); 706 707 // dispose textures 708 traverseMaterials(this.content, (material) => { 709 for (const key in material) { 710 if (key !== 'envMap' && material[key] && material[key].isTexture) { 711 material[key].dispose(); 712 } 713 } 714 }); 715 } 716} 717 718function traverseMaterials(object, callback) { 719 object.traverse((node) => { 720 if (!node.geometry) return; 721 const materials = Array.isArray(node.material) ? node.material : [node.material]; 722 materials.forEach(callback); 723 }); 724} 725 726// https://stackoverflow.com/a/9039885/1314762 727function isIOS() { 728 return ( 729 ['iPad Simulator', 'iPhone Simulator', 'iPod Simulator', 'iPad', 'iPhone', 'iPod'].includes( 730 navigator.platform, 731 ) || 732 // iPad on iOS 13 detection 733 (navigator.userAgent.includes('Mac') && 'ontouchend' in document) 734 ); 735}