Manipulating SVG Elements and Properties with UI

23 Views Asked by At

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)

1

There are 1 best solutions below

0
Carson On BEST ANSWER
  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>