// app.js -- support functions /* eslint-env console, jquery, browser, shared-node-browser */ /* eslint-disable comma-dangle */ /* global Mousetrap, TooltipManager, SettingsJSON, PARAMS, signal, assert, log, debugPrint */ // ---------------------------------------------------------------------------- var viewportUpdated, bridgedSettings, jquerySettings, tooltipManager, lessManager; function setupUI() { $('#debug-menu button, footer button') .hifiButton({ create: function() { $(this).addClass('tooltip-target') .data('tooltipster', { side: ['top','bottom'] }); } }); var $json = SettingsJSON; window.buttonHandlers = { 'test-event-bridge': function() { log('bridgedSettings.eventBridge === Window.EventBridge', bridgedSettings.eventBridge === window.EventBridge); bridgedSettings.sendEvent({ method: 'test-event-bridge' }); EventBridge.emitWebEvent('EventBridge.emitWebEvent: testing 1..2..3..'); }, 'page-reload': function() { log('triggering location.reload'); location.reload(); }, 'script-reload': function() { log('triggering script.reload'); bridgedSettings.sendEvent({ method: 'reloadClientScript' }); }, 'reset-sensors': function() { log('resetting avatar orientation'); bridgedSettings.sendEvent({ method: 'resetSensors' }); }, 'reset-to-defaults': function() { tooltipManager && tooltipManager.closeAll(); document.activeElement && document.activeElement.blur(); document.body.focus(); setTimeout(function() { bridgedSettings.sendEvent({ method: 'reset' }); },1); }, 'copy-json': $json.showSettings.bind($json, jquerySettings, null), 'paste-json': function() { $json.applyJSON( jquerySettings, 'pasted', $json.promptJSON() ); }, 'toggle-advanced-options': function(evt) { var checkbox = $(this).hifiButton('instance').checkbox; var on = checkbox.value(!checkbox.value()); $('body').toggleClass('ui-show-advanced-options', on); evt.stopPropagation(); evt.preventDefault(); if ($(this).is('.tooltipstered')) { $(this).tooltipster('instance').content((on ? 'hide' : 'show') + ' advanced options'); } if (checkbox.value()) { $('.scrollable').delay(100).animate({ scrollTop: innerHeight - $('header').innerHeight() - 24 }, 1500); } }, 'appVersion': function(evt) { evt.shiftKey && $json.showSettings(jquerySettings); }, 'errors': function(evt) { $(evt.target).is('button') && $(this).find('.output').text('').end().hide(); }, }; buttonHandlers['button-toggle-advanced-options'] = buttonHandlers['toggle-advanced-options']; Object.keys(window.buttonHandlers).forEach(function(p) { $('#' + p).on('click', window.buttonHandlers[p]); }); // ---------------------------------------------------------------- // trim whitespace in labels $('label').contents().filter(function() { if (this.nodeType !== window.Node.TEXT_NODE) { return false; } this.textContent = this.textContent.trim(); return !this.textContent.length; }).remove(); var settingsNodes = $('fieldset,button[data-for],input:not([type=radio])'); settingsNodes.each(function() { // set up the bidirectional mapping between DOM and Settings jquerySettings.registerNode(this); }); var spinnerOptions = { disabled: true, create: function() { var input = $(this), key = assert(jquerySettings.getKey(input.data('for'))); var options = input.hifiSpinner('instance').options; options.min = options.min || 0.0; bridgedSettings.getValueAsync(key, function(err, result) { input.filter(':not([data-autoenable=false])').hifiSpinner('enable'); jquerySettings.setValue(key, result); }); }, }; $( ".rows > label" ).each(function() { var label = $(this), input = label.find('input'), type = input.data('type') || input.attr('type'); label.wrap('
').parent().addClass(['hifi-'+type, type, 'row'].join(' ')) .on('focus.row, click.row, hover.row', function() { $(this).find('.tooltipstered').tooltipster('open'); }); }); debugPrint('initializing hifiSpinners'); // numeric settings $( ".number.row" ) .find( "input[data-type=number]" ) .addClass('setting') .hifiSpinner(spinnerOptions); // radio groups settings $( ".radio.rows" ) .find('label').addClass('tooltip-target').end() .addClass('setting') .hifiRadioGroup({ direction: 'vertical', disabled: true, create: function() { assert(this !== window); var group = $(this), id = this.id; var key = assert(jquerySettings.getKey(group.data('for'))); bridgedSettings.getValueAsync(key, function(err, result) { debugPrint('> GOT RADIO', key, id, result); group.filter(':not([data-autoenable=false])').hifiRadioGroup('enable'); jquerySettings.setValue(key, result); group.change(); }); }, }) // checkbox settings $( "input[type=checkbox]" ) .addClass('setting') .hifiCheckbox({ disabled: true, create: function() { var key = assert(jquerySettings.getKey(this.id)), input = $(this); input.closest('label').addClass('tooltip-target'); bridgedSettings.getValueAsync(key, function(err, result) { input.filter(':not([data-autoenable=false])').hifiCheckbox('enable'); jquerySettings.setValue(key, result); }); }, }); // slider + numeric settings // use the whole row as a tooltip target $( ".slider.row" ).addClass('tooltip-target').data('tooltipster', { distance: -20, side: ['top', 'bottom'], }).each(function(ent) { var element = $(this).find( ".control" ), input = $(this).find('input'), id = input.prop('id'), key = assert(jquerySettings.getKey(id)); var commonOptions = { disabled: true, min: parseFloat(input.prop('min') || 0), max: parseFloat(input.prop('max') || 10), step: parseFloat(input.prop('step') || 0.01), autoenable: input.data('autoenable') !== 'false', }; debugPrint('commonOptions', commonOptions); // see: https://api.jqueryui.com/slider/ for more options var slider = element.hifiSlider(Object.assign({ orientation: "horizontal", range: "min", animate: 'fast', value: 0.0 }, commonOptions)).hifiSlider('instance'); debugPrint('initializing hifiSlider->hifiSpinner'); // setup chrome up/down arrow steps and propagate input field -> slider var spinner = input.on('change', function() { var value = spinner.value(); if (isFinite(value) && slider.value() !== value) { slider.value(value); } }).addClass('setting') .hifiSpinner( Object.assign({}, commonOptions, { max: 1e4 }) ).hifiSpinner('instance'); bridgedSettings.getValueAsync(key, function(err, result) { slider.options.autoenable !== false && slider.enable(); spinner.options.autoenable !== false && spinner.enable(); spinner.value(result); }); }); $('#fps').hifiSpinner(spinnerOptions).closest('.row').css('pointer-events', 'all').on('click.subinput', function(evt) { jquerySettings.setValue('thread-update-mode', 'requestAnimationFrame'); evt.target.focus(); }); // detect invalid numbers entered into spinner fields $(':ui-hifiSpinner').on('change.validation', function(evt) { var spinner = $(this).hifiSpinner('instance'); $(this).closest('.row').toggleClass('invalid', !spinner.isValid()); }); // ---------------------------------------------------------------------------- // allow tabbing between checkboxes using the container row $(':ui-hifiCheckbox,:ui-hifiRadioButton').prop('tabindex', -1).closest('.row').prop('tabindex', 0); // select the input field text when first focused $('input').not('input[type=radio],input[type=checkbox]').on('focus', function () { var dt = (new Date - this.blurredAt); if (!(dt < 5)) { // debounce this.blurredAt = +new Date; $(this).one('mouseup.selectit', function() { $(this).select(); return false; }).select(); } }).on('blur', function(e) { this.blurredAt = new Date; }); // monitor changes to specific settings that affect the UI var monitors = { // advanced options toggle 'ui-show-advanced-options': function onChange(value) { function handle(err, result) { log('** ui-show-advanced-options updated', result+''); $('body').toggleClass('ui-show-advanced-options', !!result); jquerySettings.setValue('ui-show-advanced-options', result) } if (!onChange.fetched) { bridgedSettings.getValueAsync('ui-show-advanced-options', handle); return onChange.fetched = true; } handle(null, value); }, // UI tooltips toggle 'ui-enable-tooltips': function(value) { if (!tooltipManager) return; if (value) { tooltipManager.enable(); tooltipManager.openFocusedTooltip(); } else { tooltipManager.disable(); } }, // enable/disable fps field (based on whether thread mode is requestAnimationFrame) 'thread-update-mode': function(value) { var enabled = (value === 'requestAnimationFrame'), fps = $('#fps'); fps.hifiSpinner(enabled ? 'enable' : 'disable'); fps.closest('.row').toggleClass('disabled', !enabled); }, // flag BODY with CSS class to indicate active camera move mode 'camera-move-enabled': function(value) { $('body').toggleClass('camera-move-enabled', value); }, // update the "keybinding" and #appVersion extraParams displays '.extraParams': function extraParams(value, other) { value = bridgedSettings.extraParams; if (value.mode) { for (var p in value.mode) { // tablet-mode, hmd-mode, etc. $('body').toggleClass(p + '-mode', value.mode[p]); } document.oncontextmenu = value.mode.tablet ? function(evt) { return evt.preventDefault(),false; } : null; $('[data-type=number]').prop('type', value.mode.tablet ? 'number' : 'text'); } var versionDisplay = [ value.appVersion || '(unknown appVersion)', PARAMS.debug && '(debug)', value.mode && value.mode.tablet ? '(tablet)' : '', ].filter(Boolean).join(' | '); $('#appVersion').find('.output').text(versionDisplay).end().show(); if (value.toggleKey) { $('#toggleKey').find('.binding').empty() .append(getKeysHTML(value.toggleKey)).end().show(); } var activateLookAtOption = value.MyAvatar && value.MyAvatar.supportsLookAtSnappingEnabled; $(jquerySettings.findNodeByKey('Avatar/lookAtSnappingEnabled')) .hifiCheckbox(activateLookAtOption ? 'enable' : 'disable') .closest('.row').toggleClass('disabled', !activateLookAtOption) .css('pointer-events', 'all') // so tooltips display regardless var activateCursorOption = value.Reticle && value.Reticle.supportsScale; $('#minimal-cursor:ui-hifiCheckbox') .hifiCheckbox(activateCursorOption ? 'enable' : 'disable') .closest('.row').toggleClass('disabled', !activateCursorOption) .css('pointer-events', 'all') // so tooltips display regardless }, // gray out / ungray out page content if user is mouse looking around in Interface // (otherwise the cursor still interacts with web content...) 'Keyboard.RightMouseButton': function(localValue, key, value) { debugPrint(localValue, '... Keyboard.RightMouseButton:' + value); window.active = !value; }, }; monitors['toggle-advanced-options'] = monitors['ui-toggle-advanced-options']; monitorSettings(monitors); // disable selection // $('input').css('-webkit-user-select', 'none'); viewportUpdated.connect(lessManager, 'onViewportUpdated'); setupTooltips(); $(window).trigger('resize'); // populate viewport // set up DOM MutationObservers settingsNodes.each(function tcobo() { if (this.dataset.hifiType === 'hifiButton') { return; } var id = assert(this.dataset['for'] || this.id, 'could not id for node: ' + this.outerHTML); assert(!tcobo[id]); // detect dupes tcobo[id] = true; debugPrint('OBSERVING NODE', id, this.id || this.getAttribute('for')); jquerySettings.observeNode(this); }); // set up key bindings setupMousetrapKeys(); function getKeysHTML(binding) { var text = binding.text || ('(#' + binding.key + ')'); // translate hifi's proprietary key scheme into human-friendly KBDs return [ 'Control', 'Meta', 'Alt', 'Super', 'Menu', 'Shifted' ] .map(function(flag) { return binding['is' + flag] && flag; }) .concat(text) .filter(Boolean) .map(function(key) { return '' + key.replace('Shifted','Shift') + ''; }) .join('-'); } } // setupUI function setupTooltips() { // extract the tooltip captions var tooltips = window.tooltips = {}; var target = '[id], [data-for], [for]'; $('.tooltip') .removeClass('tooltip').addClass('x-tooltip') .each(function() { var element = $(this), input = $(element.parent().find('input').get(0) || element.closest('button').get(0)); id = element.prop('id') || element.data('for') || input.prop('id') || input.data('for'); assert(id); tooltips[id] = this.outerHTML; }).hide(); tooltipManager = new TooltipManager({ enabled: false, testmode: PARAMS.tooltiptest, viewport: PARAMS.viewport, tooltips: tooltips, elements: '#reset-to-defaults, button, input', }); viewportUpdated.connect(tooltipManager, 'updateViewport'); // tooltips aren't needed right away, so defer initializing for better page load times window.setTimeout(tooltipManager.initialize.bind(tooltipManager), 1000); } // helper for instrumenting local jquery onchange handlers function monitorSettings(options) { return Object.keys(options).reduce(function(out, id) { var key = bridgedSettings.resolve(id), domId = jquerySettings.getId(key, true); if (!domId) { var placeholder = { id: id, type: 'placeholder', toString: function() { return this.id; }, value: undefined, }; jquerySettings.registerSetting(placeholder, key); debugPrint('registered placeholder value for setting', id, key); assert(jquerySettings.findNodeByKey(key) === placeholder); } // if (domId === 'toggle-advanced-options') alert([key,id,domId, jquerySettings.findNodeByKey(key)]) assert(function assertion(){ return typeof key === 'string'; }, 'monitorSettings -- received invalid key type'); var context = { id: id, key: key, domId: domId, options: options, lastValue: undefined, initializer: function(hint) { var key = this.key, lastValue = this.lastValue; if (lastValue !== undefined) { return log('skipping repeat initializer', key, hint); } this.lastValue = lastValue = jquerySettings.getValue(key); this._onChange.call(jquerySettings, key, lastValue, undefined, hint); }, _onChange: function _onChange(key, value) { var currentValue = this.getValue(context.id), jsonCurrentValue = JSON.stringify(currentValue); if (jsonCurrentValue === context.jsonLastValue) { if (jsonCurrentValue !== undefined) { debugPrint([context.key, '_onChange', this, 'not triggering _onChange for duplicated value']); } return; } context.jsonLastValue = jsonCurrentValue; var args = [].slice.call(arguments, 0); debugPrint('monitorSetting._onChange', context.key, value, [].concat(args).pop()); context.options[context.id].apply(this, [ currentValue ].concat(args)); }, onValueReceived: function(key) { if (key === this.key) { this._onChange.apply(bridgedSettings, arguments); } }, onMutationEvent: function(event) { if (event.key === this.key) { context._onChange.call(jquerySettings, event.key, event.value, event.oldValue, event.hifiType+':mutation'); } }, onPendingRequestsFinished: function onPendingRequestsFinished() { bridgedSettings.pendingRequestsFinished.disconnect(this, 'onPendingRequestsFinished'); this.initializer('pendingRequestsFinished'); }, }; bridgedSettings.valueReceived.connect(context, 'onValueReceived'); jquerySettings.mutationEvent.connect(context, 'onMutationEvent'); if (bridgedSettings.pendingRequestCount()) { bridgedSettings.pendingRequestsFinished.connect(context, 'onPendingRequestsFinished'); } else { window.setTimeout(context.initializer.bind(context, 'monitorSettings init'), 1); } return context; }, {}); } function initializeDOM() { Object.defineProperty(window, 'active', { get: function() { return window._active; }, set: function(nv) { nv = !!nv; window._active = nv; debugPrint('window.active == ' + nv); if (!nv) { document.activeElement && document.activeElement.blur(); document.body.focus(); tooltipManager && tooltipManager.disable(); debugPrint('TOOLTIPS DISABLED'); } else if (tooltipManager && bridgedSettings) { if (bridgedSettings.getValue('ui-enable-tooltips')){ tooltipManager.enable(); debugPrint('TOOLTIPS RE-ENABLED'); } } $('body').toggleClass('active', window._active); }, }); $('body').toggleClass('active', window._active = true); function checkAnim(evt) { if (!checkAnim.disabled) { if ($('.scrollable').is(':animated')) { $('.scrollable').stop(); log(evt.type, 'stop animation'); } } } viewportUpdated = signal(function viewportUpdated(viewport) {}); function triggerViewportUpdate() { var viewport = { inner: { width: innerWidth, height: innerHeight }, client: { width: document.body.clientWidth || window.innerWidth, height: document.body.clientHeight || window.innerHeight, }, min: { width: window.innerWidth / 3, height: 32 }, max: { width: window.innerWidth * 7/8, height: window.innerHeight * 7/8 }, }; debugPrint('viewportUpdated', viewport); PARAMS.viewport = Object.assign(PARAMS.viewport||{}, viewport); viewportUpdated(viewport, triggerViewportUpdate.lastViewport); triggerViewportUpdate.lastViewport = viewport; } $(window).on({ resize: function resize() { window.clearTimeout(resize.to); resize.to = window.setTimeout(triggerViewportUpdate, 100); }, mousedown: checkAnim, mouseup: checkAnim, scroll: checkAnim, wheel: checkAnim, blur: function() { log('** BLUR ** '); $('body').addClass('window-blurred'); document.body.focus(); document.activeElement && document.activeElement.blur(); // tooltipManager.closeAll(); }, focus: function() { log('** FOCUS **'); $('body').removeClass('window-blurred'); }, }); } function setupMousetrapKeys() { if (!window.Mousetrap) { return log('WARNING: window.Mousetrap not found; not configurating keybindings'); } mousetrapMultiBind({ 'ctrl+a, option+a': function global(evt, combo) { $(document.activeElement).filter('input').select(); }, 'enter': function global(evt, combo) { var node = document.activeElement; if ($(node).is('input')) { log('enter on input element'); tooltipManager.closeAll(); node.blur(); var nexts = $('[tabindex],input,:focusable').not('[tabindex=-1],.ui-slider-handle'); nexts.add(nexts.find('input')); nexts = nexts.toArray(); if (~nexts.indexOf(node)) { var nextActive = nexts[nexts.indexOf(node)+1]; log('setting focus to', nextActive); $(nextActive).focus(); } else { log('could not deduce next tabbable element', nexts.length, this); } } return true; }, 'ctrl+w': bridgedSettings.sendEvent.bind(bridgedSettings, { method: 'window.close' }), 'r': location.reload.bind(location), 'space': function global(evt, combo) { log('SPACE', evt.target, document.activeElement); $(document.activeElement).filter('.row').find(':ui-hifiCheckbox,:ui-hifiRadioButton').click(); if (!$(document.activeElement).is('input,.ui-widget')) { return false; } return true; }, }); // $('input').addClass('mousetrap'); function mousetrapMultiBind(a, b) { var obj = typeof a === 'object' ? a : Object.defineProperty({}, a, {enumerable: true, value: b }); Object.keys(obj).forEach(function(key) { var method = obj[key].name === 'global' ? 'bindGlobal' : 'bind'; key.split(/\s*,\s*/).forEach(function(combo) { debugPrint('Mousetrap', method, combo, typeof obj[key]); Mousetrap[method](combo, function(evt, combo) { debugPrint('Mousetrap', method, combo); return obj[key].apply(this, arguments); }); }); }); } } // support the URL having a #node-id (or #debug=1&node-id) hash fragment jumping to that element function jumpToAnchor(id) { id = JSON.stringify(id); $('[id='+id+'],[name='+id+']').first().each(function() { log('jumpToAnchor', id); $(this).show(); this.scrollIntoView({ behavior: 'smooth' }); }); };