Source: device.js

/**
 * @license MIT
 * Copyright © 2025 Steve Butler (henspace.com)
 * 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.
 */

/**
 * @module hcje/device
 * @description
 * Various utilities for managing the device the app is running on. In this context, the window size is also regarded
 * as device information as not controlled by the app itself and more represents the environment in which it is
 * running.
 */

/**
 * Get the dimensions of an element. 
 * @param {Element} [element = window] - Element to check. Defaults to the window.
 * @returns {{width: number, height: number}}
 */
export function getDimensions(element) {
  let width;
  let height;
  if (element) {
    width = element.clientWidth || Number.parseInt(element.style.width);
    height = element.clientHeight || Number.parseInt(element.style.height);
  } else {
    width = window.innerWidth;
    height = window.innerHeight;
  }
  return {width, height};
}

/** 
 * Get an appropriate scale to allow a rectangle to fit in the body.
 * Typically no element is provided and the body will have been set in css to fit the screen.
 * @param {number} width - Rectangle width
 * @param {height} height - Rectangle height
 * @param {boolean} cover - Should scale result in the rectangle covering the element or fitting within it. Fits if
 *   false.
 * @param {Object} options
 * @param {number} options.margin - Margin around the rectangle.
 * @param {Element} options.element - Element to use for bounds.
 *  Defaults to using the window.
 * @returns {number}
 */
export function getScaleToFitOrCover(width, height, cover, options) {
  const margin = options.margin ?? 0;
  const dimensions = getDimensions(options.element);
  const availableWidth = dimensions.width - 2 * margin;
  const availableHeight = dimensions.height - 2 * margin;
  
  let scale;
  if (cover) {
    scale = Math.max(availableWidth / width, availableHeight / height);
  } else {
    scale = Math.min(availableWidth / width, availableHeight / height);
  }
  console.debug(`${cover ? 'Cover' : 'Fit'} [${width}x${height}] into [${availableWidth}x${availableHeight} at scale ${scale.toFixed(2)}]`);
  return scale;
}

/** 
 * Get an appropriate scale to allow a rectangle to fit in the body.
 * The body should have been set in css to fit the screen.
 * @param {number} width - Rectangle width
 * @param {height} height - Rectangle height
 * @param {Object} options
 * @param {number} options.margin - Margin around rectangle
 * @param {Element} options.element - Element to use for bounds.
 *  Defaults to window innerWidth;
 * @returns {number}
 */
export function getScaleToFit(width, height, options) {
  return getScaleToFitOrCover(width, height, false, options ?? {});
}

/** 
 * Get an appropriate scale to allow a rectangle to cover the body.
 * The body should have been set in css to fit the screen.
 * @param {number} width - Rectangle width
 * @param {height} height - Rectangle height
 * @param {Object} options
 * @param {number} options.margin - Margin around rectangle
 * @param {Element} options.element - Element to use for bounds.
 *  Defaults to window innerWidth;
 * @returns {number}
 */
export function getScaleToCover(width, height, options) {
  return getScaleToFitOrCover(width, height, true, options ?? {});
}



/**
 * Detail provided in the details property of the options object provided to the 
 * [CustomEvent]{@link https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent}.
 * @typedef {Object} VirtualKeyDetail
 * @property {string} key - see the 
 *    [KeyboardEvent.key property]{@link https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key}.
 */

/**
 * @typedef {Object} KeyEventListener
 * @property {function()} callback - Function to call on event
 * @property {boolean} noRepeat - If true, key repeats are ignored.
 */

/**
 * Keyboard handler.
 */
export class Keyboard {
  /** @type {string} */
  static #VIRTUAL_KEYDOWN_EVENT = 'virtualKeyDown';

  /** Map of listeners.
   * @type {Map<string, function>} */
  #listeners;
  
  /**
   * Construct the keyboard handler.
   */ 
  constructor() {
    this.#listeners = new Map();
    addEventListener('keydown', (evt) => {
      const listener = this.#listeners.get(evt.key);
      if (listener && !(listener.noRepeat && evt.repeat)) {
        listener.callback()
      }
    });
    addEventListener(Keyboard.#VIRTUAL_KEYDOWN_EVENT, (evt) => {
      this.#listeners.get(evt.detail.key)?.callback();
    });
  }
  /**
   * Add keydown listener.
   * @param {string} key - String representation of key. 
   *   See the [KeyboardEvent.key values]{@link https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_value}.
   * @param {module:hcje/device~KeyEventListener} listener - Listener for the key press.
   */
  addDownListener(key, listener) {
    this.#listeners.set(key, listener);
  }
  /**
   * Remove keydown listener previously added via a call to [addDownListener]{@link module:hcje/device.Keyboard#addDownListener}.
   * @param {string} key - String representation of key. 
   */
  removeDownListener(key) {
    this.#listeners.delete(key);
  }

  /**
   * Dispatch a virtual key down event.
   * @param {string} key - String representation of key. 
   *   See the [KeyboardEvent.key values]{@link https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_value}.
   * @param {EventTarget} [dispatcher = window] - Event target used to dispatch the event.
   */
  static simulateKeydown(key, dispatcher = window) {
    dispatcher.dispatchEvent(new CustomEvent(Keyboard.#VIRTUAL_KEYDOWN_EVENT, {detail: {key}}));
  }
}


/**
 * Put the element into fullscreen mode.
 * @param {Element} [element = Document.documentElement] - The element to go into fullscreen.
 * @param {Object} [options] - See [requestFullscreen options]{@link https://developer.mozilla.org/en-US/docs/Web/API/Element/requestFullscreen#options}
 */ 
export function enterFullscreen(element = document.documentElement, options) {
  if (!document.fullscreenElement && element.requestFullscreen) {
    element.requestFullscreen()
      .then(() => console.debug(`Into fullscreen`))
      .catch((error) => {
        console.error(`Could not enter fullscreen mode: %{error.message}`);
      });
  }
}

/**
 * Exit fullscreen mode.
 */
export function exitFullscreen() {
  if (document.fullscreenElement && document.exitFullscreen) {
    document.exitFullscreen()
      .then(() => console.debug('Exited fullscreen mode.'))
      .catch((err) => console.error('Failed to exit fullscreen mode:', err));
  }
}

/**
 * Test if touch api supported.
 * @returns {boolean}
 */
export function supportsTouch() {
  return window.hasOwnProperty('ontouchstart');
}