Search code examples
javascriptsvg

Manipulating SVG Elements and Properties with UI


I want to have a panel that appears when clicking on a child element of an SVG.

This panel should display the properties of the selected object,

and allow direct modification of those properties, which would then affect the element. How can I achieve this?

(pure javascript no use any package)


Solution

    1. Add a click event to detect which element was clicked (svgElem.onclick)
    2. Create a panel (div)
    3. For each attribute (attributes can get all current attributes), add an input field in the panel
    4. Add an input.onchange event handler to update the element's attribute: input.onchange = () => {element.setAttribute(attr.name, input.value)}

    simple version

    <style>
      .selected {outline: 2px dashed red;}
      body {display: flex;}
    </style>
    
    <svg width="500" height="500" xmlns="http://www.w3.org/2000/svg">
      <rect width="30" height="60" x="80" y="10" style="transform: rotate(30deg)"/>
      <line x1="143" y1="100" x2="358" y2="55" fill="#000000" stroke="#000000" stroke-width="4" stroke-dasharray="8,8"
            opacity="1"></line>
      <circle cx="50" cy="50" r="10" fill="green"/>
    </svg>
    <div id="info-panel"></div>
    
    <script>
      const svgElement = document.querySelector('svg')
      svgElement.addEventListener('click', (event) => {
        document.querySelector(`[class~='selected']`)?.classList.remove("selected")
        const targetElement = event.target
        targetElement.classList.add("selected")
        displayAttrsPanel(targetElement)
      })
    
      function displayAttrsPanel(element) {
        const infoPanel = document.getElementById('info-panel')
        infoPanel.innerHTML = ''
        const attributes = element.attributes
    
        for (let i = 0; i < attributes.length; i++) {
          const attr = attributes[i]
          const frag = createInputFragment(attr.name, attr.value)
    
          const input = frag.querySelector('input')
          input.onchange = () => {
            element.setAttribute(attr.name, input.value)
          }
          infoPanel.append(frag)
        }
      }
    
      function createInputFragment(name, value) {
        return document.createRange()
          .createContextualFragment(
            `<div><label>${name}<input value="${value}"></div>`
          )
      }
    </script>

    Full code

    The above example is a relatively concise version. The following example provides more settings, such as:

    • type: input can perform simple judgments to distinguish whether it is input.type={color, number, text}, etc.
    • Delete Button: add the button on each properties for delete.
    • New Attribute Button: The ability to add new attributes through the panel
    • dialog: For more complex attributes like {class, style, d, points}, pupup an individual dialog can be used to set each value separately

    <style>
      .selected {outline: 2px dashed red;}
      body {display: flex;}
    </style>
    
    <svg width="500" height="500" xmlns="http://www.w3.org/2000/svg">
      <rect width="30" height="60" x="80" y="10" style="transform: rotate(30deg);opacity: 0.8;"/>
      <line x1="143" y1="100" x2="358" y2="55" fill="#000000" stroke="#000000" stroke-width="4" stroke-dasharray="8,8"
            opacity="1"></line>
      <circle class="cute big" cx="50" cy="50" r="10" fill="green"/>
      <polygon points="225,124 114,195 168,288 293,251.123456"></polygon>
      <polyline points="50,150 100,75 150,50 200,140 250,140" fill="yellow"></polyline>
      <path d="M150 300 L75 200 L225 200 Z" fill="purple"></path>
    </svg>
    <div id="info-panel"></div>
    
    <script>
      const svgElement = document.querySelector('svg')
      svgElement.addEventListener('click', (event) => {
        document.querySelector(`[class~='selected']`)?.classList.remove("selected")
        const targetElement = event.target
        targetElement.classList.add("selected")
        displayAttrsPanel(targetElement)
      })
    
      function displayAttrsPanel(element) {
        const infoPanel = document.getElementById('info-panel')
        infoPanel.innerHTML = ''
        // Sorting is an optional feature designed to ensure the presentation order remains as fixed as possible.
        const attributes = [...element.attributes].sort((a, b)=>{
          return a.name < b.name ? -1 :
            a.name > b.name ? 1 : 0
        })
    
        for (let i = 0; i < attributes.length; i++) {
          const attr = attributes[i]
          const frag = createInputFragment(attr.name, attr.value)
    
          // add event
          const input = frag.querySelector('input')
          const deleteBtn = frag.querySelector('button')
          input.onchange = () => {
            element.setAttribute(attr.name, input.value)
          }
    
          // allow delete attribute
          deleteBtn.onclick = () => {
            element.removeAttribute(attr.name)
            displayAttrsPanel(element) // refresh
          }
    
          // For special case, when clicking the label, sub-items can be displayed separately, making it convenient for editing.
          const label = frag.querySelector("label")
          if (["class", "style", "points", "d"].includes(attr.name)) {
            label.style.backgroundColor = "#d8f9d8" // for the user know it can click
            label.style.cursor = "pointer"
            let splitFunc
            const cbOptions = []
            switch (attr.name) {
              case "points": // https://www.w3schools.com/graphics/svg_polygon.asp
              case "class":
                splitFunc=value=>value.split(" ")
                cbOptions.push((newValues)=>element.setAttribute(attr.name, newValues.join(" ")))
                break
              case "style":
                splitFunc=value=>value.split(";")
                cbOptions.push((newValues)=>element.setAttribute(attr.name, newValues.join(";")))
                break
              case "d": // https://www.w3schools.com/graphics/svg_path.asp
                const regex = /([MLHVCSQTAZ])([^MLHVCSQTAZ]*)/g;
                splitFunc=value=>value.match(regex)
                cbOptions.push((newValues)=>element.setAttribute(attr.name, newValues.join("")))
            }
    
            label.addEventListener("click", () => {
              openEditDialog(attr.name, attr.value,
                splitFunc,
                (newValues) => {
                  for (const option of cbOptions) {
                    option(newValues)
                  }
                  displayAttrsPanel(element) // refresh
              })
            })
          }
          infoPanel.append(frag)
        }
    
        // Add New Attribute Button
        const frag = document.createRange()
          .createContextualFragment(
            `<div><label>+<input placeholder="attribute"><input placeholder="value"></label><button>Add</button></div>`
          )
        const [inputAttr, inputVal] = frag.querySelectorAll("input")
        frag.querySelector("button").onclick = () => {
          const name = inputAttr.value.trim()
          const value = inputVal.value.trim()
          if (name && value) {
            element.setAttribute(name, value)
            inputAttr.value = ''
            inputVal.value = ''
            displayAttrsPanel(element) // refresh
          }
        }
        infoPanel.appendChild(frag)
      }
    
      function createInputFragment(name, value) {
        const frag = document.createRange()
          .createContextualFragment(
            `<div><label>${name}</label><input value="${value}"><button>-</button></div>`
          )
    
        const input = frag.querySelector("input")
    
        switch (name) {
          case "stroke":
          case "fill":
            input.type = "color"
            break
          case "opacity":
            input.type = "range"
            input.step = "0.05"
            input.max = "1"
            input.min = "0"
            break
          case "cx":
          case "cy":
          case "r":
          case "rx":
          case "ry":
          case "x":
          case "y":
          case "x1":
          case "x2":
          case "y1":
          case "y2":
          case "stroke-width":
            input.type = "number"
            break
          default:
            input.type = "text"
        }
        return frag
      }
    
      function openEditDialog(name, valueStr, splitFunc, callback) {
        const frag = document.createRange()
          .createContextualFragment(
            `<dialog open>
    <div style="display: flex;flex-direction: column;"></div>
    <button id="add">Add</button>
    <button id="save">Save</button></dialog>`
          )
    
        const dialog = frag.querySelector("dialog")
        const divValueContainer = frag.querySelector('div')
        const addBtn = frag.querySelector("button#add")
        const saveBtn = frag.querySelector("button#save")
    
        const values = splitFunc(valueStr)
    
        for (const val of values) {
          const input = document.createElement("input")
          input.value = val
          divValueContainer.append(input)
        }
    
        // Add
        addBtn.onclick = () => {
          const input = document.createElement("input")
          divValueContainer.append(input)
        }
    
        // Save
        saveBtn.onclick = () => {
          const newValues = []
          dialog.querySelectorAll("input").forEach(e=>{
            if (e.value !== "") {
              newValues.push(e.value)
            }
          })
          callback(newValues)
          dialog.close()
        }
    
        document.body.append(dialog)
      }
    </script>