WME-UI

UI Library for Waze Map Editor Greasy Fork scripts

This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.greasyfork.org/scripts/450320/1281847/WME-UI.js

// ==UserScript==
// @name         WME UI
// @version      0.2.4
// @description  UI Library for Waze Map Editor Greasy Fork scripts
// @license      MIT License
// @author       Anton Shevchuk
// @namespace    https://greasyfork.org/users/227648-anton-shevchuk
// @supportURL   https://github.com/AntonShevchuk/wme-ui/issues
// @match        https://*.waze.com/editor*
// @match        https://*.waze.com/*/editor*
// @exclude      https://*.waze.com/user/editor*
// @icon         https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://anton.shevchuk.name&size=64
// @grant        none
// ==/UserScript==

/* jshint esversion: 8 */
/* global W, I18n */

// WARNING: this is unsafe!
let unsafePolicy = {
  createHTML: string => string
}

// Feature testing
if (window.trustedTypes && window.trustedTypes.createPolicy) {
  unsafePolicy = window.trustedTypes.createPolicy('unsafe', {
    createHTML: string => string,
  })
}

class WMEUI {
  /**
   * Normalize title or UID
   * @param string
   * @returns {string}
   */
  static normalize (string) {
    return string.replace(/\W+/gi, '-').toLowerCase()
  }

  /**
   * Inject CSS styles
   * @param {String} css
   * @return void
   */
  static addStyle (css) {
    let style = document.createElement('style')
    style.type = 'text/css' // is required
    style.innerHTML = unsafePolicy.createHTML(css)
    document.querySelector('head').appendChild(style)
  }

  /**
   * Add translation for I18n object
   * @param {String} uid
   * @param {Object} data
   * @return void
   */
  static addTranslation (uid, data) {
    if (!data.en) {
      console.error('Default translation `en` is required')
    }
    let locale = I18n.currentLocale()
    I18n.translations[locale][uid] = data[locale] || data.en
  }

  /**
   * Create and register shortcut
   * @param {String} name
   * @param {String} desc
   * @param {String} group
   * @param {String} title
   * @param {String} shortcut
   * @param {Function} callback
   * @param {Object} scope
   * @return void
   */
  static addShortcut (name, desc, group, title, shortcut, callback, scope = null) {
    new WMEUIShortcut(name, desc, group, title, shortcut, callback, scope).register()
  }
}

/**
 * God class, create it once
 */
class WMEUIHelper {
  constructor (uid) {
    this.uid = WMEUI.normalize(uid)
    this.index = 0
  }

  /**
   * Generate unque ID
   * @return {string}
   */
  generateId () {
    this.index++
    return this.uid + '-' + this.index
  }

  /**
   * Create a panel for the sidebar
   * @param {String} title
   * @param {Object} attributes
   * @return {WMEUIHelperPanel}
   */
  createPanel (title, attributes = {}) {
    return new WMEUIHelperPanel(this.uid, this.generateId(), title, attributes)
  }

  /**
   * Create a tab for the sidebar
   * @param {String} title
   * @param {Object} attributes
   * @return {WMEUIHelperTab}
   */
  createTab (title, attributes = {}) {
    return new WMEUIHelperTab(this.uid, this.generateId(), title, attributes)
  }

  /**
   * Create a modal window
   * @param {String} title
   * @param {Object} attributes
   * @return {WMEUIHelperModal}
   */
  createModal (title, attributes = {}) {
    return new WMEUIHelperModal(this.uid, this.generateId(), title, attributes)
  }

  /**
   * Create a field set
   * @param {String} title
   * @param {Object} attributes
   * @return {WMEUIHelperFieldset}
   */
  createFieldset (title, attributes = {}) {
    return new WMEUIHelperFieldset(this.uid, this.generateId(), title, attributes)
  }
}

/**
 * Basic for all UI elements
 */
class WMEUIHelperElement {
  constructor (uid, id, title = null, attributes = {}) {
    this.uid = uid
    this.id = id
    this.title = title
    // HTML attributes
    this.attributes = attributes
    // DOM element
    this.element = null
    // Children
    this.elements = []
  }

  /**
   * Add WMEUIHelperElement to container
   * @param {WMEUIHelperElement} element
   * @return {WMEUIHelperElement} element
   */
  addElement (element) {
    this.elements.push(element)
    return element
  }

  /**
   * @param {HTMLElement} element
   * @return {HTMLElement}
   */
  applyAttributes (element) {
    for (let attr in this.attributes) {
      if (this.attributes.hasOwnProperty(attr)) {
        element[attr] = this.attributes[attr]
      }
    }
    return element
  }

  /**
   * @return {HTMLElement}
   */
  html () {
    if (!this.element) {
      this.element = this.toHTML()
      this.element.className += ' ' + this.uid + ' ' + this.uid + '-' + this.id
    }
    return this.element
  }

  /**
   * Build and return HTML elements for injection
   * @return {HTMLElement}
   */
  toHTML () {
    throw new Error('Abstract method')
  }
}

/**
 * Basic for all UI containers
 */
class WMEUIHelperContainer extends WMEUIHelperElement {
  /**
   * Create and add button
   * For Tab Panel Modal Fieldset
   * @param {String} id
   * @param {String} title
   * @param {String} description
   * @param {Function} callback
   * @param {String} shortcut
   * @return {WMEUIHelperElement} element
   */
  addButton (id, title, description, callback, shortcut = null) {
    return this.addElement(new WMEUIHelperControlButton(this.uid, id, title, description, callback, shortcut))
  }

  /**
   * Create buttons
   * @param {Object} buttons
   */
  addButtons (buttons) {
    for (let btn in buttons) {
      if (buttons.hasOwnProperty(btn)) {
        this.addButton(
          btn,
          buttons[btn].title,
          buttons[btn].description,
          buttons[btn].callback,
          buttons[btn].shortcut,
        )
      }
    }
  }

  /**
   * Create checkbox
   * For Tab, Panel, Modal, or Fieldset
   * @param {String} id
   * @param {String} title
   * @param {Function} callback
   * @param {Boolean} checked
   * @return {WMEUIHelperElement} element
   */
  addCheckbox (id, title, callback, checked = false) {
    return this.addElement(
      new WMEUIHelperControlInput(this.uid, id, title, {
        'id': this.uid + '-' + id,
        'onclick': callback,
        'type': 'checkbox',
        'value': 1,
        'checked': checked,
      })
    )
  }

  /**
   * Create and add WMEUIHelperDiv element
   * @param {String} id
   * @param {String} innerHTML
   * @param {Object} attributes
   * @return {WMEUIHelperElement} element
   */
  addDiv (id, innerHTML = null, attributes = {}) {
    return this.addElement(new WMEUIHelperDiv(this.uid, id, innerHTML, attributes))
  }

  /**
   * Create and add WMEUIHelperFieldset element
   * For Tab, Panel, Modal
   * @param {String} id
   * @param {String} title
   * @return {WMEUIHelperElement} element
   */
  addFieldset (id, title) {
    return this.addElement(new WMEUIHelperFieldset(this.uid, id, title))
  }

  /**
   * Create text input
   * @param {String} id
   * @param {String} title
   * @param {Function} callback
   * @param {String} value
   * @return {WMEUIHelperElement} element
   */
  addInput (id, title, callback, value = '') {
    return this.addElement(
      new WMEUIHelperControlInput(this.uid, id, title, {
        'id': this.uid + '-' + id,
        'onchange': callback,
        'type': 'text',
        'value': value,
      })
    )
  }

  /**
   * Create number input
   * @param {String} id
   * @param {String} title
   * @param {Function} callback
   * @param {Number} value
   * @param {Number} min
   * @param {Number} max
   * @param {Number} step
   * @return {WMEUIHelperElement} element
   */
  addNumber (id, title, callback, value = 0, min, max, step = 10) {
    return this.addElement(
      new WMEUIHelperControlInput(this.uid, id, title, {
        'id': this.uid + '-' + id,
        'onchange': callback,
        'type': 'number',
        'value': value,
        'min': min,
        'max': max,
        'step': step,
      })
    )
  }

  /**
   * Create radiobutton
   * @param {String} id
   * @param {String} title
   * @param {Function} callback
   * @param {String} name
   * @param {String} value
   * @param {Boolean} checked
   * @return {WMEUIHelperElement} element
   */
  addRadio (id, title, callback, name, value, checked = false) {
    return this.addElement(
      new WMEUIHelperControlInput(this.uid, id, title, {
        'id': this.uid + '-' + id,
        'name': name,
        'onclick': callback,
        'type': 'radio',
        'value': value,
        'checked': checked,
      })
    )
  }

  /**
   * Create range input
   * @param {String} id
   * @param {String} title
   * @param {Function} callback
   * @param {Number} value
   * @param {Number} min
   * @param {Number} max
   * @param {Number} step
   * @return {WMEUIHelperElement} element
   */
  addRange (id, title, callback, value, min, max, step = 10) {
    return this.addElement(
      new WMEUIHelperControlInput(this.uid, id, title, {
        'id': this.uid + '-' + id,
        'onchange': callback,
        'type': 'range',
        'value': value,
        'min': min,
        'max': max,
        'step': step,
      })
    )
  }

  /**
   * Create and add WMEUIHelperText element
   * @param {String} id
   * @param {String} text
   * @return {WMEUIHelperElement} element
   */
  addText (id, text) {
    return this.addElement(new WMEUIHelperText(this.uid, id, text))
  }
}

class WMEUIHelperFieldset extends WMEUIHelperContainer {
  toHTML () {
    // Fieldset legend
    let legend = document.createElement('legend')
    legend.innerHTML = unsafePolicy.createHTML(this.title)

    // Container for buttons
    let controls = document.createElement('div')
    controls.className = 'controls'
    // Append buttons to container
    this.elements.forEach(element => controls.append(element.html()))

    let fieldset = document.createElement('fieldset')
    fieldset = this.applyAttributes(fieldset)
    fieldset.append(legend)
    fieldset.append(controls)
    return fieldset
  }
}

class WMEUIHelperPanel extends WMEUIHelperContainer {
  container () {
    return document.getElementById('edit-panel')
  }

  inject () {
    this.container().append(this.html())
  }

  toHTML () {
    // Label of the panel
    let label = document.createElement('label')
    label.className = 'control-label'
    label.innerHTML = unsafePolicy.createHTML(this.title)
    // Container for buttons
    let controls = document.createElement('div')
    controls.className = 'controls'
    // Append buttons to panel
    this.elements.forEach(element => controls.append(element.html()))
    // Build panel
    let group = document.createElement('div')
    group.className = 'form-group'
    group.append(label)
    group.append(controls)
    return group
  }
}

class WMEUIHelperTab extends WMEUIHelperContainer {
  constructor (uid, id, title, attributes = {}) {
    super(uid, id, title, attributes)
    this.icon = attributes.icon
    this.image = attributes.image
  }

  async inject () {
    const { tabLabel, tabPane } = W.userscripts.registerSidebarTab(this.uid)

    tabLabel.innerText = this.title
    tabLabel.title = this.title

    tabPane.append(this.html())
  }

  toHTML () {
    // Label of the panel
    let header = document.createElement('div')
    header.className = 'panel-header-component settings-header'
    header.style.alignItems = 'center'
    header.style.display = 'flex'
    header.style.gap = '9px'
    header.style.justifyContent = 'stretch'
    header.style.padding = '8px'
    header.style.width = '100%'

    if (this.icon) {
      let icon = document.createElement('i')
      icon.className = 'w-icon panel-header-component-icon w-icon-' + this.icon
      icon.style.fontSize = '24px'
      header.append(icon)
    }

    if (this.image) {
      let img = document.createElement('img')
      img.style.height = '42px'
      img.src = this.image
      header.append(img)
    }

    let title = document.createElement('div')
    title.className = 'feature-id-container'
    title.innerHTML = unsafePolicy.createHTML(
      '<div class="feature-id-container"><wz-overline>' + this.title + '</wz-overline></div>'
    )
    header.append(title)

    // Container for buttons
    let controls = document.createElement('div')
    controls.className = 'button-toolbar'

    // Append buttons to container
    this.elements.forEach(element => controls.append(element.html()))

    // Build form group
    let group = document.createElement('div')
    group.className = 'form-group'
    group.append(header)
    group.append(controls)

    return group
  }
}

class WMEUIHelperModal extends WMEUIHelperContainer {
  container () {
    return document.getElementById('panel-container')
  }

  inject () {
    this.container().append(this.html())
  }

  toHTML () {
    // Header and close button
    let close = document.createElement('a')
    close.className = 'close-panel'
    close.onclick = function () {
      panel.remove()
    }

    let title = document.createElement('h5')
    title.innerHTML = unsafePolicy.createHTML(this.title)

    let header = document.createElement('div')
    header.className = 'header'
    header.prepend(title)
    header.prepend(close)

    // Body
    let body = document.createElement('div')
    body.className = 'body'

    // Append buttons to panel
    this.elements.forEach(element => body.append(element.html()))

    // Container
    let archivePanel = document.createElement('div')
    archivePanel.className = 'archive-panel'
    archivePanel.append(header)
    archivePanel.append(body)

    let panel = document.createElement('div')
    panel.className = 'panel panel--to-be-deprecated show'
    panel.append(archivePanel)

    return panel
  }
}

/**
 * Just div, can be with text
 */
class WMEUIHelperDiv extends WMEUIHelperElement {
  toHTML () {
    let div = document.createElement('div')
    div = this.applyAttributes(div)
    div.id = this.uid + '-' + this.id
    if (this.title) {
      div.innerHTML = unsafePolicy.createHTML(this.title)
    }
    return div
  }
}

/**
 * Just paragraph with text
 */
class WMEUIHelperText extends WMEUIHelperElement {
  toHTML () {
    let p = document.createElement('p')
    p = this.applyAttributes(p)
    p.innerHTML = unsafePolicy.createHTML(this.title)
    return p
  }
}

/**
 * Base class for controls
 */
class WMEUIHelperControl extends WMEUIHelperElement {
  constructor (uid, id, title, attributes = {}) {
    super(uid, id, title, attributes)
    if (!attributes.name) {
      this.attributes.name = this.id
    }
  }
}

/**
 * Input with label inside the div
 */
class WMEUIHelperControlInput extends WMEUIHelperControl {
  toHTML () {
    let input = document.createElement('input')
    input = this.applyAttributes(input)

    let label = document.createElement('label')
    label.htmlFor = input.id
    label.innerHTML = unsafePolicy.createHTML(this.title)

    let container = document.createElement('div')
    container.className = 'controls-container'
    container.append(input)
    container.append(label)
    return container
  }
}

/**
 * Button with shortcut if neeeded
 */
class WMEUIHelperControlButton extends WMEUIHelperControl {
  constructor (uid, id, title, description, callback, shortcut = null) {
    super(uid, id, title)
    this.description = description
    this.callback = callback
    if (shortcut) {
      /* name, desc, group, title, shortcut, callback, scope */
      new WMEUIShortcut(
        this.uid + '-' + this.id,
        this.description,
        this.uid,
        title,
        shortcut,
        this.callback
      ).register()
    }
  }

  toHTML () {
    let button = document.createElement('button')
    button.className = 'waze-btn waze-btn-small waze-btn-white'
    button.innerHTML = unsafePolicy.createHTML(this.title)
    button.title = this.description
    button.onclick = this.callback
    return button
  }
}

/**
 * Based on the code from the WazeWrap library
 */
class WMEUIShortcut {
  /**
   * @param {String} name
   * @param {String} desc
   * @param {String} group
   * @param {String} title
   * @param {String} shortcut
   * @param {Function} callback
   * @param {Object} scope
   * @return {WMEUIShortcut}
   */
  constructor (name, desc, group, title, shortcut, callback, scope = null) {
    this.name = name
    this.desc = desc
    this.group = WMEUI.normalize(group) || 'default'
    this.title = title
    this.shortcut = null
    this.callback = callback
    this.scope = ('object' === typeof scope) ? scope : null

    /* Setup shortcut */
    if (shortcut && shortcut.length > 0) {
      this.shortcut = { [shortcut]: name }
    }
  }

  /**
   * @param {String} group name
   * @param {String} title of the shortcut section
   */
  static setGroupTitle (group, title) {
    group = WMEUI.normalize(group)

    if (!I18n.translations[I18n.currentLocale()].keyboard_shortcuts.groups[group]) {
      I18n.translations[I18n.currentLocale()].keyboard_shortcuts.groups[group] = {
        description: title,
        members: {}
      }
    } else {
      I18n.translations[I18n.currentLocale()].keyboard_shortcuts.groups[group].description = title
    }
  }

  /**
   * Add translation for shortcut
   */
  addTranslation () {
    if (!I18n.translations[I18n.currentLocale()].keyboard_shortcuts.groups[this.group]) {
      I18n.translations[I18n.currentLocale()].keyboard_shortcuts.groups[this.group] = {
        description: this.title,
        members: {
          [this.name]: this.desc
        }
      }
    }
    I18n.translations[I18n.currentLocale()].keyboard_shortcuts.groups[this.group].members[this.name] = this.desc
  }

  /**
   * Register group/action/event/shortcut
   */
  register () {
    /* Try to initialize new group */
    this.addGroup()

    /* Clear existing actions with same name and create new */
    this.addAction()

    /* Try to register new event */
    this.addEvent()

    /* Finally, register the shortcut */
    this.registerShortcut()
  }

  /**
   * Determines if the shortcut's action already exists.
   * @private
   */
  doesGroupExist () {
    return 'undefined' !== typeof W.accelerators.Groups[this.group]
      && 'undefined' !== typeof W.accelerators.Groups[this.group].members
  }

  /**
   * Determines if the shortcut's action already exists.
   * @private
   */
  doesActionExist () {
    return 'undefined' !== typeof W.accelerators.Actions[this.name]
  }

  /**
   * Determines if the shortcut's event already exists.
   * @private
   */
  doesEventExist () {
    return 'undefined' !== typeof W.accelerators.events.dispatcher._events[this.name]
      && W.accelerators.events.dispatcher._events[this.name].length > 0
      && this.callback === W.accelerators.events.dispatcher._events[this.name][0].func
      && this.scope === W.accelerators.events.dispatcher._events[this.name][0].obj
  }

  /**
   * Creates the shortcut's group.
   * @private
   */
  addGroup () {
    if (this.doesGroupExist()) return

    W.accelerators.Groups[this.group] = []
    W.accelerators.Groups[this.group].members = []
  }

  /**
   * Registers the shortcut's action.
   * @private
   */
  addAction () {
    if (this.doesActionExist()) {
      W.accelerators.Actions[this.name] = null
    }
    W.accelerators.addAction(this.name, { group: this.group })
  }

  /**
   * Registers the shortcut's event.
   * @private
   */
  addEvent () {
    if (this.doesEventExist()) return
    W.accelerators.events.register(this.name, this.scope, this.callback)
  }

  /**
   * Registers the shortcut's keyboard shortcut.
   * @private
   */
  registerShortcut () {
    if (this.shortcut) {
      /* Setup translation for shortcut */
      this.addTranslation()
      W.accelerators._registerShortcuts(this.shortcut)
    }
  }
}