Repository: SVG-Edit/svgedit (language: python)

Issue:
Rewrite the zoom selector


**NOTE:**
This issue is now funded on Bountysource (https://app.bountysource.com/issues/109641308-minor-display-issue-with-zoom-selector)
However, the fix needs to be a **FULL REWRITE** of the zoom selector web component without using the elix library. 
The component source is: src/editor/components/seZoom.js
Native JS, CSS preferred but another library is possible if it is popular and actively maintained.

Obviously, the fix needs to work consistently on Safari, Firefox and Chrome.

**To Reproduce**
1. Open zoom selector

**Expected behavior**
All options must be visible with correct hover

**SVG-Edit environment (IMPORTANT)**
- Version: 7.0.0

**Desktop (please complete the following information):**
 - OS: Windows 10
 - Browser : Firefox
 - Version : 91 ESR

**Screenshots**
![unnamed](https://user-images.githubusercontent.com/63210012/144289018-8d260aaa-0922-415a-973c-f10d1f1aa98c.png)



Discussion / hints:
The same bug with SVG-Edit environment 
    Version: 7.0.0

Desktop:
    OS: Windows 10
    Browser : Google Chrome
    Version : 97

This bug isn't present on MacOS, both Chrome and Safari
Hey! Anyone working on this issue? Im interested
> Hey! Anyone working on this issue? Im interested

I don't think so. You can start on this issue. 
JF 
Hi, 

What's the intended styling for the popup part of the selector? 

I assume at least the hover styling is broken in the screenshot and latest build

Thanks
We have no strict guidelines but clearly the current style is indeed broken. 

Fix patch:
diff --git a/package-lock.json b/package-lock.json
index 6da9dcbc5..d1ab28936 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -41676,4 +41676,4 @@
       "dev": true
     }
   }
-}
+}
\ No newline at end of file
diff --git a/src/editor/components/seZoom.js b/src/editor/components/seZoom.js
index b238d24fd..b619c4748 100644
--- a/src/editor/components/seZoom.js
+++ b/src/editor/components/seZoom.js
@@ -1,73 +1,180 @@
 /* globals svgEditor */
-import ListComboBox from 'elix/define/ListComboBox.js'
-import * as internal from 'elix/src/base/internal.js'
-import { templateFrom, fragmentFrom } from 'elix/src/core/htmlLiterals.js'
-import NumberSpinBox from '../dialogs/se-elix/define/NumberSpinBox.js'
-
-/**
- * @class Dropdown
- */
-class Zoom extends ListComboBox {
-  /**
-    * @function get
-    * @returns {PlainObject}
-    */
-  get [internal.defaultState] () {
-    return Object.assign(super[internal.defaultState], {
-      inputPartType: NumberSpinBox,
-      src: 'logo.svg',
-      inputsize: '100%'
-    })
+const template = document.createElement('template')
+template.innerHTML = `
+  <style>
+  input{
+    border:unset;
+    background-color:var(--input-color);
+    min-width:unset;
+    width:40px;
+    height:23px;
+    padding:1px 2px;
+    border:2px;
+    font: inherit;
+    margin: 2px 1px 0px 2px;
+    box-sizing:border-box;
+    text-align: center;
+    border-radius: 3px 0px 0px 3px;
+  }
+  #tool-wrapper{
+    height:20px;
+    display:flex;
+    align-items:center;
+  }
+  #icon{
+    margin-bottom:1px
+  }
+  #spinner{
+    display:flex;
+    flex-direction:column;
+  }
+  #spinner > div {
+    height: 11px;
+    width: 7px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    font-size: 7px;
+    border-left:solid 1px transparent;
+    border-right:solid 1px transparent;
+    background-color:var(--input-color);
+  }
+  #arrow-up{
+    height:9px;
+    margin-top: 2px;
+    margin-bottom: 1px;
   }
+  #down{
+    width:18px;
+    height:23px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background-color:var(--input-color);
+    border-radius: 0px 3px 3px 0px;
+    margin: 2px 5px 0px 1px;
+  }
+  #down > img {
+    margin-top: 2px;
+  }
+  #options-container {
+    position:fixed
+    display:flex;
+    flex-direction:column;
+    background-color:var(--icon-bg-color);
+    border:solid 1px white;
+    box-shadow:0 0px 10px rgb(0 0 0 / 50%);
+  }
+  ::slotted(*) {
+    margin:2px;
+    padding:3px;
+    color:white;
+  }
+  ::slotted(*:hover) {
+    background-color: rgb(43, 60, 69);
+  }
+  </style>
+  <div id="tool-wrapper">
+    <img id="icon" alt="icon" width="18" height="18"/>
+    <input/>
+    <div id="spinner">
+      <div id="arrow-up">▲</div>
+      <div id="arrow-down">▼</div>
+    </div>
+    <div id="down">
+      <img width="16" height="8" src="./images/arrow_down.svg"/>
+    </div>
+  </div>
+  <div id="options-container" style="display:none">
+    <slot></slot>
+  </div>
+`
 
-  /**
-    * @function get
-    * @returns {PlainObject}
-  */
-  get [internal.template] () {
-    const result = super[internal.template]
-    const source = result.content.getElementById('source')
-    // add a icon before our dropdown
-    source.prepend(fragmentFrom.html`
-      <img src="zoom" alt="icon" width="18" height="18">
-      </img>
-      `.cloneNode(true))
-    // change the style so it fits in our toolbar
-    result.content.append(
-      templateFrom.html`
-        <style>
-        [part~="source"] {
-          grid-template-columns: 20px 1fr auto;
-        }
-        ::slotted(*) {
-          padding: 4px;
-          width: 100%;
-        }
-        [part~="popup"] {
-          width: 150%;
-        }
-        elix-number-spin-box {
-          background-color: var(--input-color);
-          border-radius: 3px;
-          height: 20px !important;
-          margin-top: 1px;
-        }
-        elix-number-spin-box::part(spin-button) {
-            padding: 0px;
-        }
-
-        </style>
-      `.content
+class SeZoom extends HTMLElement {
+  constructor () {
+    super()
+
+    this.handleMouseDown = this.handleMouseDown.bind(this)
+    this.handleMouseUp = this.handleMouseUp.bind(this)
+    this.handleKeyDown = this.handleKeyDown.bind(this)
+    this.initPopup = this.initPopup.bind(this)
+    this.handleInput = this.handleInput.bind(this)
+
+    // create the shadowDom and insert the template
+    this._shadowRoot = this.attachShadow({ mode: 'open' })
+    // locate the component
+    this._shadowRoot.append(template.content.cloneNode(true))
+
+    // prepare the slot element
+    this.slotElement = this._shadowRoot.querySelector('slot')
+    this.slotElement.addEventListener(
+      'slotchange',
+      this.handleOptionsChange.bind(this)
+    )
+
+    // hookup events for the input box
+    this.inputElement = this._shadowRoot.querySelector('input')
+    this.inputElement.addEventListener('click', this.handleClick.bind(this))
+    this.inputElement.addEventListener('change', this.handleInput)
+    this.inputElement.addEventListener('keydown', this.handleKeyDown)
+
+    this.clickArea = this._shadowRoot.querySelector('#down')
+    this.clickArea.addEventListener('click', this.handleClick.bind(this))
+
+    // set src for imageElement
+    this.imageElement = this._shadowRoot.querySelector('img')
+    this.imageElement.setAttribute(
+      'src',
+      (this.imgPath =
+        svgEditor.configObj.curConfig.imgPath + '/' + this.getAttribute('src'))
+    )
+
+    // hookup events for arrow buttons
+    this.arrowUp = this._shadowRoot.querySelector('#arrow-up')
+    this.arrowUp.addEventListener('click', this.increment.bind(this))
+    this.arrowUp.addEventListener('mousedown', e =>
+      this.handleMouseDown('up', true)
+    )
+    this.arrowUp.addEventListener('mouseleave', e => this.handleMouseUp('up'))
+    this.arrowUp.addEventListener('mouseup', e => this.handleMouseUp('up'))
+
+    this.arrowDown = this._shadowRoot.querySelector('#arrow-down')
+    this.arrowDown.addEventListener('click', this.decrement.bind(this))
+    this.arrowDown.addEventListener('mousedown', e =>
+      this.handleMouseDown('down', true)
     )
-    return result
+    this.arrowDown.addEventListener('mouseleave', e =>
+      this.handleMouseUp('down')
+    )
+    this.arrowDown.addEventListener('mouseup', e => this.handleMouseUp('down'))
+
+    this.optionsContainer = this._shadowRoot.querySelector(
+      '#options-container'
+    )
+
+    // add an event listener to close the popup
+    document.addEventListener('click', e => this.handleClose(e))
+    this.changedTimeout = null
+  }
+
+  static get observedAttributes () {
+    return ['value']
   }
 
   /**
-   * @function observedAttributes
-   * @returns {any} observed
+   * @function get
+   * @returns {any}
    */
-  static get observedAttributes () {
-    return ['title', 'src', 'inputsize', 'value']
+  get value () {
+    return this.getAttribute('value')
+  }
+
+  /**
+   * @function set
+   * @returns {void}
+   */
+  set value (value) {
+    this.setAttribute('value', value)
   }
 
   /**
@@ -78,116 +185,196 @@ class Zoom extends ListComboBox {
    * @returns {void}
    */
   attributeChangedCallback (name, oldValue, newValue) {
-    if (oldValue === newValue && name !== 'src') return
+    if (oldValue === newValue) {
+      switch (name) {
+        case 'value':
+          if (parseInt(this.inputElement.value) !== newValue) {
+            this.inputElement.value = newValue
+          }
+          break
+      }
+
+      return
+    }
+
     switch (name) {
-      case 'title':
-      // this.$span.setAttribute('title', `${newValue} ${shortcut ? `[${shortcut}]` : ''}`);
-        break
-      case 'src':
-        {
-          const { imgPath } = svgEditor.configObj.curConfig
-          this.src = imgPath + '/' + newValue
-        }
-        break
-      case 'inputsize':
-        this.inputsize = newValue
-        break
-      default:
-        super.attributeChangedCallback(name, oldValue, newValue)
+      case 'value':
+        this.inputElement.value = newValue
+        this.dispatchEvent(
+          new CustomEvent('change', { detail: { value: newValue } })
+        )
         break
     }
   }
 
   /**
-    * @function [internal.render]
-    * @param {PlainObject} changed
-    * @returns {void}
-    */
-  [internal.render] (changed) {
-    super[internal.render](changed)
-    if (this[internal.firstRender]) {
-      this.$img = this.shadowRoot.querySelector('img')
-      this.$input = this.shadowRoot.getElementById('input')
-    }
-    if (changed.src) {
-      this.$img.setAttribute('src', this[internal.state].src)
+   * @function handleOptionsChange
+   * @returns {void}
+   */
+  handleOptionsChange () {
+    if (this.slotElement.assignedElements().length > 0) {
+      this.options = this.slotElement.assignedElements()
+      this.selectedValue = this.options[0].textContent
+
+      this.initPopup()
+
+      this.options.forEach(option => {
+        option.addEventListener('click', e => this.handleSelect(e))
+      })
     }
-    if (changed.inputsize) {
-      this.$input.shadowRoot.querySelector('[part~="input"]').style.width = this[internal.state].inputsize
+  }
+
+  /**
+   * @function handleClick
+   * @returns {void}
+   */
+  handleClick () {
+    this.optionsContainer.style.display = 'flex'
+    this.inputElement.select()
+    this.initPopup()
+  }
+
+  /**
+   * @function handleSelect
+   * @param {Event} e
+   * @returns {void}
+   */
+  handleSelect (e) {
+    this.value = e.target.getAttribute('value')
+    this.title = e.target.getAttribute('text')
+  }
+
+  /**
+   * @function handleShow
+   * @returns {void}
+   * initialises the popup menu position
+   */
+  initPopup () {
+    const zoomPos = this.getBoundingClientRect()
+    const popupPos = this.optionsContainer.getBoundingClientRect()
+    const top = zoomPos.top - popupPos.height
+    const left = zoomPos.left
+
+    this.optionsContainer.style.position = 'fixed'
+    this.optionsContainer.style.top = `${top}px`
+    this.optionsContainer.style.left = `${left}px`
+  }
+
+  /**
+   * @function handleClose
+   * @param {Event} e
+   * @returns {void}
+   * Close the popup menu
+   */
+  handleClose (e) {
+    if (e.target !== this) {
+      this.optionsContainer.style.display = 'none'
+      this.inputElement.blur()
     }
-    if (changed.inputPartType) {
-      const self = this
-      this.$input.setAttribute('step', '10')
-      this.$input.setAttribute('min', '0')
-      // Handle NumberSpinBox input.
-      this.$input.addEventListener('change', function (e) {
-        e.preventDefault()
-        const value = e.detail?.value
-        if (value) {
-          const changeEvent = new CustomEvent('change', { detail: { value } })
-          self.dispatchEvent(changeEvent)
-        }
-      })
-      // Wire up handler on new input.
-      this.addEventListener('close', (e) => {
-        e.preventDefault()
-        const value = e.detail?.closeResult?.getAttribute('value')
-        if (value) {
-          const closeEvent = new CustomEvent('change', { detail: { value } })
-          this.dispatchEvent(closeEvent)
-        }
-      })
+  }
+
+  /**
+   * @function handleInput
+   * @returns {void}
+   */
+  handleInput () {
+    if (this.changedTimeout) {
+      clearTimeout(this.changedTimeout)
     }
+
+    this.changedTimeout = setTimeout(this.triggerInputChanged.bind(this), 500)
   }
 
   /**
-   * @function src
-   * @returns {string} src
+   * @function triggerInputChanged
+   * @returns {void}
    */
-  get src () {
-    return this[internal.state].src
+  triggerInputChanged () {
+    const newValue = this.inputElement.value
+    this.value = newValue
   }
 
   /**
-   * @function src
+   * @function increment
    * @returns {void}
    */
-  set src (src) {
-    this[internal.setState]({ src })
+  increment () {
+    this.value = parseInt(this.value) + 10
   }
 
   /**
-   * @function inputsize
-   * @returns {string} src
+   * @function decrement
+   * @returns {void}
    */
-  get inputsize () {
-    return this[internal.state].inputsize
+  decrement () {
+    if (this.value - 10 <= 0) {
+      this.value = 10
+    } else {
+      this.value = parseInt(this.value) - 10
+    }
   }
 
   /**
-   * @function src
+   * @function handleMouseDown
+   * @param {string} dir
+   * @param {boolean} isFirst
    * @returns {void}
+   * Increment/Decrement on mouse held down, if its the first call add a delay before starting
    */
-  set inputsize (inputsize) {
-    this[internal.setState]({ inputsize })
+  handleMouseDown (dir, isFirst) {
+    if (dir === 'up') {
+      this.incrementHold = true
+      !isFirst && this.increment()
+
+      setTimeout(
+        () => {
+          if (this.incrementHold) {
+            this.handleMouseDown(dir, false)
+          }
+        },
+        isFirst ? 500 : 50
+      )
+    } else if (dir === 'down') {
+      this.decrementHold = true
+      !isFirst && this.decrement()
+
+      setTimeout(
+        () => {
+          if (this.decrementHold) {
+            this.handleMouseDown(dir, false)
+          }
+        },
+        isFirst ? 500 : 50
+      )
+    }
   }
 
   /**
-   * @function value
-   * @returns {string} src
+   * @function handleMouseUp
+   * @param {string} dir
+   * @returns {void}
    */
-  get value () {
-    return this[internal.state].value
+  handleMouseUp (dir) {
+    if (dir === 'up') {
+      this.incrementHold = false
+    } else {
+      this.decrementHold = false
+    }
   }
 
   /**
-   * @function value
+   * @function handleKeyDown
+   * @param {Event} e
    * @returns {void}
    */
-  set value (value) {
-    this[internal.setState]({ value })
+  handleKeyDown (e) {
+    if (e.key === 'ArrowUp') {
+      this.increment()
+    } else if (e.key === 'ArrowDown') {
+      this.decrement()
+    }
   }
 }
 
 // Register
-customElements.define('se-zoom', Zoom)
+customElements.define('se-zoom', SeZoom)
diff --git a/src/editor/panels/BottomPanel.html b/src/editor/panels/BottomPanel.html
index 9867334d3..8d50e7360 100644
--- a/src/editor/panels/BottomPanel.html
+++ b/src/editor/panels/BottomPanel.html
@@ -1,15 +1,15 @@
 <div id="tools_bottom">
     <!-- Zoom buttons -->
     <se-zoom id="zoom" src="zoom.svg" title="Change zoom level" inputsize="40px">
-      <se-text value="1000" text="1000"></se-text>
-      <se-text value="400" text="400"></se-text>
-      <se-text value="200" text="200"></se-text>
-      <se-text value="100" text="100"></se-text>
-      <se-text value="50" text="50"></se-text>
-      <se-text value="25" text="25"></se-text>
+      <se-text value="1000" text="1000%"></se-text>
+      <se-text value="400" text="400%"></se-text>
+      <se-text value="200" text="200%"></se-text>
+      <se-text value="100" text="100%"></se-text>
+      <se-text value="50" text="50%"></se-text>
+      <se-text value="25" text="25%"></se-text>
       <se-text value="canvas" text="tools.fit_to_canvas"></se-text>
       <se-text value="selection" text="tools.fit_to_sel"></se-text>
-      <se-text value="layer" text="tools.fit_to_layer_content"></se-text>"
+      <se-text value="layer" text="tools.fit_to_layer_content"></se-text>
       <se-text value="content" text="tools.fit_to_all"></se-text>
     </se-zoom>
     <se-colorpicker id="fill_color" src="fill.svg" label="properties.fill_color" type="fill"></se-colorpicker>


Test patch:
diff --git a/cypress/e2e/unit/zoom.cy.js b/cypress/e2e/unit/zoom.cy.js
new file mode 100644
index 000000000..50b9d3496
--- /dev/null
+++ b/cypress/e2e/unit/zoom.cy.js
@@ -0,0 +1,217 @@
+import { visitAndApproveStorage } from '../../support/ui-test-helper.js'
+
+describe('UI - Zoom tool', function () {
+  beforeEach(() => {
+    visitAndApproveStorage()
+  })
+
+  it('should be able to open', function () {
+    cy.get('#zoom')
+      .click()
+      .shadow()
+      .find('#options-container')
+      .should('have.css', 'display', 'flex')
+  })
+
+  it('should be able to close', function () {
+    cy.get('#zoom')
+      .click()
+      .shadow()
+      .find('#options-container')
+      .should('have.css', 'display', 'flex')
+
+    cy.get('#tool_select')
+      .click({ force: true })
+      .get('#zoom')
+      .shadow()
+      .find('#options-container')
+      .should('have.css', 'display', 'none')
+  })
+
+  it('should be able to input zoom level', function () {
+    cy.get('#canvasBackground')
+      .invoke('attr', 'width')
+      .then(width => {
+        cy.get('#zoom')
+          .shadow()
+          .find('input')
+          .type('200')
+        cy.get('#tool_select')
+          .click({ force: true })
+        cy.get('#canvasBackground')
+          .invoke('attr', 'width')
+          .should('equal', (width * 2).toString())
+      })
+  })
+
+  it('should be able to increment zoom level', function () {
+    cy.get('#canvasBackground')
+      .invoke('attr', 'width')
+      .then(width => {
+        cy.get('#zoom')
+          .shadow()
+          .find('#arrow-up')
+          .click()
+        cy.get('#canvasBackground')
+          .invoke('attr', 'width')
+          .should('equal', (width * 1.1).toString())
+      })
+  })
+
+  it('should be able to decrement zoom level', function () {
+    cy.get('#canvasBackground')
+      .invoke('attr', 'width')
+      .then(width => {
+        cy.get('#zoom')
+          .shadow()
+          .find('#arrow-down')
+          .click()
+        cy.get('#canvasBackground')
+          .invoke('attr', 'width')
+          .should('equal', (width * 0.9).toString())
+      })
+  })
+
+  it('should be able to select from popup', function () {
+    cy.get('#canvasBackground')
+      .invoke('attr', 'width')
+      .then(width => {
+        cy.get('#zoom')
+          .click()
+          .find('se-text')
+          .first()
+          .click({ force: true })
+          .invoke('attr', 'value')
+          .then(value => {
+            cy.get('#canvasBackground')
+              .invoke('attr', 'width')
+              .should('equal', (width * (value / 100)).toString())
+              .toString()
+          })
+      })
+  })
+
+  it('should be able to resize to fit the current selection', function () {
+    cy.get('#tool_path').click({ force: true })
+    cy.get('#svgcontent')
+      .trigger('mousedown', 50, 50, { force: true })
+      .trigger('mouseup', { force: true })
+      .trigger('mousemove', 100, 50, { force: true })
+      .trigger('mousedown', 100, 50, { force: true })
+      .trigger('mouseup', { force: true })
+      .trigger('mousemove', 75, 150, { force: true })
+      .trigger('mousedown', 75, 150, { force: true })
+      .trigger('mouseup', { force: true })
+      .trigger('mousemove', 0, 0, { force: true })
+      .trigger('mousedown', 0, 0, { force: true })
+      .trigger('mouseup', { force: true })
+
+    cy.get('#tool_select')
+      .click({ force: true })
+      .trigger('mousedown', 50, 50, { force: true })
+      .trigger('mousemove', 100, 50, { force: true })
+      .trigger('mouseup', { force: true })
+
+    cy.get('#canvasBackground')
+      .invoke('attr', 'width')
+      .then(width => {
+        cy.get('#zoom')
+          .click()
+          .find("se-text[value='layer']")
+          .click({ force: true })
+        cy.get('#zoom')
+          .invoke('attr', 'value')
+          .then(value => {
+            cy.get('#canvasBackground')
+              .invoke('attr', 'width')
+              .should('not.equal', '100')
+              .toString()
+          })
+      })
+  })
+
+  it('should be able to resize to fit the canvas', function () {
+    cy.get('#canvasBackground')
+      .invoke('attr', 'width')
+      .then(width => {
+        cy.get('#zoom')
+          .click()
+          .find("se-text[value='canvas']")
+          .click({ force: true })
+        cy.get('#zoom')
+          .invoke('attr', 'value')
+          .then(value => {
+            cy.get('#canvasBackground')
+              .invoke('attr', 'width')
+              .should('not.equal', '100')
+              .toString()
+          })
+      })
+  })
+
+  it('should be able to resize to fit the current layer', function () {
+    cy.get('#tool_path').click({ force: true })
+    cy.get('#svgcontent')
+      .trigger('mousedown', 50, 50, { force: true })
+      .trigger('mouseup', { force: true })
+      .trigger('mousemove', 100, 50, { force: true })
+      .trigger('mousedown', 100, 50, { force: true })
+      .trigger('mouseup', { force: true })
+      .trigger('mousemove', 75, 150, { force: true })
+      .trigger('mousedown', 75, 150, { force: true })
+      .trigger('mouseup', { force: true })
+      .trigger('mousemove', 0, 0, { force: true })
+      .trigger('mousedown', 0, 0, { force: true })
+      .trigger('mouseup', { force: true })
+
+    cy.get('#canvasBackground')
+      .invoke('attr', 'width')
+      .then(width => {
+        cy.get('#zoom')
+          .click()
+          .find("se-text[value='layer']")
+          .click({ force: true })
+        cy.get('#zoom')
+          .invoke('attr', 'value')
+          .then(value => {
+            cy.get('#canvasBackground')
+              .invoke('attr', 'width')
+              .should('not.equal', '100')
+              .toString()
+          })
+      })
+  })
+
+  it('should be able to resize to fit the current content', function () {
+    cy.get('#tool_path').click({ force: true })
+    cy.get('#svgcontent')
+      .trigger('mousedown', 50, 50, { force: true })
+      .trigger('mouseup', { force: true })
+      .trigger('mousemove', 100, 50, { force: true })
+      .trigger('mousedown', 100, 50, { force: true })
+      .trigger('mouseup', { force: true })
+      .trigger('mousemove', 75, 150, { force: true })
+      .trigger('mousedown', 75, 150, { force: true })
+      .trigger('mouseup', { force: true })
+      .trigger('mousemove', 0, 0, { force: true })
+      .trigger('mousedown', 0, 0, { force: true })
+      .trigger('mouseup', { force: true })
+
+    cy.get('#canvasBackground')
+      .invoke('attr', 'width')
+      .then(width => {
+        cy.get('#zoom')
+          .click()
+          .find("se-text[value='content']")
+          .click({ force: true })
+        cy.get('#zoom')
+          .invoke('attr', 'value')
+          .then(value => {
+            cy.get('#canvasBackground')
+              .invoke('attr', 'width')
+              .should('not.equal', '100')
+              .toString()
+          })
+      })
+  })
+})