Source: svgcanvas/selected-elem.js

/**
 * Tools for SVG selected element operation.
 * @module selected-elem
 * @license MIT
 *
 * @copyright 2010 Alexis Deveria, 2010 Jeff Schiller
 */

import { NS } from './namespaces.js';
import * as hstry from './history.js';
import * as pathModule from './path.js';
import {
  isNullish, getStrokedBBoxDefaultVisible, setHref, getElem, getHref, getVisibleElements,
  findDefs, getRotationAngle, getRefElem, getBBox as utilsGetBBox, walkTreePost, assignAttributes, getFeGaussianBlur
} from './utilities.js';
import {
  transformPoint, matrixMultiply, transformListToTransform
} from './math.js';
import {
  recalculateDimensions
} from './recalculate.js';
import {
  isGecko
} from '../common/browser.js'; // , supportsEditableText
import { getParents } from '../editor/components/jgraduate/Util.js';

const {
  MoveElementCommand, BatchCommand, InsertElementCommand, RemoveElementCommand, ChangeElementCommand
} = hstry;

let elementContext_ = null;

/**
* @function module:selected-elem.init
* @param {module:selected-elem.elementContext} elementContext
* @returns {void}
*/
export const init = function (elementContext) {
  elementContext_ = elementContext;
};

/**
* Repositions the selected element to the bottom in the DOM to appear on top of
* other elements.
* @function module:selected-elem.SvgCanvas#moveToTopSelectedElem
* @fires module:selected-elem.SvgCanvas#event:changed
* @returns {void}
*/
export const moveToTopSelectedElem = function () {
  const [ selected ] = elementContext_.getSelectedElements();
  if (!isNullish(selected)) {
    const t = selected;
    const oldParent = t.parentNode;
    const oldNextSibling = t.nextSibling;
    t.parentNode.append(t);
    // If the element actually moved position, add the command and fire the changed
    // event handler.
    if (oldNextSibling !== t.nextSibling) {
      elementContext_.addCommandToHistory(new MoveElementCommand(t, oldNextSibling, oldParent, 'top'));
      elementContext_.call('changed', [ t ]);
    }
  }
};

/**
* Repositions the selected element to the top in the DOM to appear under
* other elements.
* @function module:selected-elem.SvgCanvas#moveToBottomSelectedElement
* @fires module:selected-elem.SvgCanvas#event:changed
* @returns {void}
*/
export const moveToBottomSelectedElem = function () {
  const [ selected ] = elementContext_.getSelectedElements();
  if (!isNullish(selected)) {
    let t = selected;
    const oldParent = t.parentNode;
    const oldNextSibling = t.nextSibling;
    let { firstChild } = t.parentNode;
    if (firstChild.tagName === 'title') {
      firstChild = firstChild.nextSibling;
    }
    // This can probably be removed, as the defs should not ever apppear
    // inside a layer group
    if (firstChild.tagName === 'defs') {
      firstChild = firstChild.nextSibling;
    }
    t = t.parentNode.insertBefore(t, firstChild);
    // If the element actually moved position, add the command and fire the changed
    // event handler.
    if (oldNextSibling !== t.nextSibling) {
      elementContext_.addCommandToHistory(new MoveElementCommand(t, oldNextSibling, oldParent, 'bottom'));
      elementContext_.call('changed', [ t ]);
    }
  }
};

/**
* Moves the select element up or down the stack, based on the visibly
* intersecting elements.
* @function module:selected-elem.SvgCanvas#moveUpDownSelected
* @param {"Up"|"Down"} dir - String that's either 'Up' or 'Down'
* @fires module:selected-elem.SvgCanvas#event:changed
* @returns {void}
*/
export const moveUpDownSelected = function (dir) {
  const selectedElements = elementContext_.getSelectedElements();
  const selected = selectedElements[0];
  if (!selected) { return; }

  elementContext_.setCurBBoxes([]);
  // curBBoxes = [];
  let closest; let foundCur;
  // jQuery sorts this list
  const list = elementContext_.getIntersectionList(getStrokedBBoxDefaultVisible([ selected ]));
  if (dir === 'Down') { list.reverse(); }

  Array.prototype.forEach.call(list, function (el) {
    if (!foundCur) {
      if (el === selected) {
        foundCur = true;
      }
      return true;
    }
    closest = el;
    return false;
  });
  if (!closest) { return; }

  const t = selected;
  const oldParent = t.parentNode;
  const oldNextSibling = t.nextSibling;
  if (dir === 'Down') {
    closest.insertAdjacentElement('beforebegin', t);
  } else {
    closest.insertAdjacentElement('afterend', t);
  }
  // If the element actually moved position, add the command and fire the changed
  // event handler.
  if (oldNextSibling !== t.nextSibling) {
    elementContext_.addCommandToHistory(new MoveElementCommand(t, oldNextSibling, oldParent, 'Move ' + dir));
    elementContext_.call('changed', [ t ]);
  }
};

/**
* Moves selected elements on the X/Y axis.
* @function module:selected-elem.SvgCanvas#moveSelectedElements
* @param {Float} dx - Float with the distance to move on the x-axis
* @param {Float} dy - Float with the distance to move on the y-axis
* @param {boolean} undoable - Boolean indicating whether or not the action should be undoable
* @fires module:selected-elem.SvgCanvas#event:changed
* @returns {BatchCommand|void} Batch command for the move
*/

export const moveSelectedElements = function (dx, dy, undoable = true) {
  const selectedElements = elementContext_.getSelectedElements();
  const currentZoom = elementContext_.getCurrentZoom();
  // if undoable is not sent, default to true
  // if single values, scale them to the zoom
  if (!Array.isArray(dx)) {
    dx /= currentZoom;
    dy /= currentZoom;
  }

  const batchCmd = new BatchCommand('position');
  let i = selectedElements.length;
  while (i--) {
    const selected = selectedElements[i];
    if (!isNullish(selected)) {
      const xform = elementContext_.getSVGRoot().createSVGTransform();
      const tlist = selected.transform?.baseVal;

      // dx and dy could be arrays
      if (Array.isArray(dx)) {
        xform.setTranslate(dx[i], dy[i]);
      } else {
        xform.setTranslate(dx, dy);
      }

      if (tlist.numberOfItems) {
        tlist.insertItemBefore(xform, 0);
      } else {
        tlist.appendItem(xform);
      }

      const cmd = recalculateDimensions(selected);
      if (cmd) {
        batchCmd.addSubCommand(cmd);
      }

      elementContext_.gettingSelectorManager().requestSelector(selected).resize();
    }
  }
  if (!batchCmd.isEmpty()) {
    if (undoable) {
      elementContext_.addCommandToHistory(batchCmd);
    }
    elementContext_.call('changed', selectedElements);
    return batchCmd;
  }
  return undefined;
};

/**
* Create deep DOM copies (clones) of all selected elements and move them slightly
* from their originals.
* @function module:selected-elem.SvgCanvas#cloneSelectedElements
* @param {Float} x Float with the distance to move on the x-axis
* @param {Float} y Float with the distance to move on the y-axis
* @returns {void}
*/
export const cloneSelectedElements = function (x, y) {
  const selectedElements = elementContext_.getSelectedElements();
  const currentGroup = elementContext_.getCurrentGroup();
  let i; let elem;
  const batchCmd = new BatchCommand('Clone Elements');
  // find all the elements selected (stop at first null)
  const len = selectedElements.length;

  function index(el) {
    if (!el) return -1;
    let i = 0;
    do {
      i++;
    } while (el == el.previousElementSibling);
    return i;
  }

  /**
* Sorts an array numerically and ascending.
* @param {Element} a
* @param {Element} b
* @returns {Integer}
*/
  function sortfunction(a, b) {
    return (index(b) - index(a));
  }
  selectedElements.sort(sortfunction);
  for (i = 0; i < len; ++i) {
    elem = selectedElements[i];
    if (isNullish(elem)) { break; }
  }
  // use slice to quickly get the subset of elements we need
  const copiedElements = selectedElements.slice(0, i);
  elementContext_.clearSelection(true);
  // note that we loop in the reverse way because of the way elements are added
  // to the selectedElements array (top-first)
  const drawing = elementContext_.getDrawing();
  i = copiedElements.length;
  while (i--) {
    // clone each element and replace it within copiedElements
    elem = copiedElements[i] = drawing.copyElem(copiedElements[i]);
    (currentGroup || drawing.getCurrentLayer()).append(elem);
    batchCmd.addSubCommand(new InsertElementCommand(elem));
  }

  if (!batchCmd.isEmpty()) {
    elementContext_.addToSelection(copiedElements.reverse()); // Need to reverse for correct selection-adding
    moveSelectedElements(x, y, false);
    elementContext_.addCommandToHistory(batchCmd);
  }
};
/**
* Aligns selected elements.
* @function module:selected-elem.SvgCanvas#alignSelectedElements
* @param {string} type - String with single character indicating the alignment type
* @param {"selected"|"largest"|"smallest"|"page"} relativeTo
* @returns {void}
*/
export const alignSelectedElements = function (type, relativeTo) {
  const selectedElements = elementContext_.getSelectedElements();
  const bboxes = []; // angles = [];
  const len = selectedElements.length;
  if (!len) { return; }
  let minx = Number.MAX_VALUE; let maxx = Number.MIN_VALUE;
  let miny = Number.MAX_VALUE; let maxy = Number.MIN_VALUE;
  let curwidth = Number.MIN_VALUE; let curheight = Number.MIN_VALUE;
  for (let i = 0; i < len; ++i) {
    if (isNullish(selectedElements[i])) { break; }
    const elem = selectedElements[i];
    bboxes[i] = getStrokedBBoxDefaultVisible([ elem ]);

    // now bbox is axis-aligned and handles rotation
    switch (relativeTo) {
    case 'smallest':
      if (((type === 'l' || type === 'c' || type === 'r' || type === 'left' || type === 'center' || type === 'right') &&
          (curwidth === Number.MIN_VALUE || curwidth > bboxes[i].width)) ||
          ((type === 't' || type === 'm' || type === 'b' || type === 'top' || type === 'middle' || type === 'bottom') &&
            (curheight === Number.MIN_VALUE || curheight > bboxes[i].height))
      ) {
        minx = bboxes[i].x;
        miny = bboxes[i].y;
        maxx = bboxes[i].x + bboxes[i].width;
        maxy = bboxes[i].y + bboxes[i].height;
        curwidth = bboxes[i].width;
        curheight = bboxes[i].height;
      }
      break;
    case 'largest':
      if (((type === 'l' || type === 'c' || type === 'r' || type === 'left' || type === 'center' || type === 'right') &&
          (curwidth === Number.MIN_VALUE || curwidth < bboxes[i].width)) ||
          ((type === 't' || type === 'm' || type === 'b' || type === 'top' || type === 'middle' || type === 'bottom') &&
            (curheight === Number.MIN_VALUE || curheight < bboxes[i].height))
      ) {
        minx = bboxes[i].x;
        miny = bboxes[i].y;
        maxx = bboxes[i].x + bboxes[i].width;
        maxy = bboxes[i].y + bboxes[i].height;
        curwidth = bboxes[i].width;
        curheight = bboxes[i].height;
      }
      break;
    default: // 'selected'
      if (bboxes[i].x < minx) { minx = bboxes[i].x; }
      if (bboxes[i].y < miny) { miny = bboxes[i].y; }
      if (bboxes[i].x + bboxes[i].width > maxx) { maxx = bboxes[i].x + bboxes[i].width; }
      if (bboxes[i].y + bboxes[i].height > maxy) { maxy = bboxes[i].y + bboxes[i].height; }
      break;
    }
  } // loop for each element to find the bbox and adjust min/max

  if (relativeTo === 'page') {
    minx = 0;
    miny = 0;
    maxx = elementContext_.getContentW();
    maxy = elementContext_.getContentH();
  }

  const dx = new Array(len);
  const dy = new Array(len);
  for (let i = 0; i < len; ++i) {
    if (isNullish(selectedElements[i])) { break; }
    // const elem = selectedElements[i];
    const bbox = bboxes[i];
    dx[i] = 0;
    dy[i] = 0;
    switch (type) {
    case 'l': // left (horizontal)
    case 'left': // left (horizontal)
      dx[i] = minx - bbox.x;
      break;
    case 'c': // center (horizontal)
    case 'center': // center (horizontal)
      dx[i] = (minx + maxx) / 2 - (bbox.x + bbox.width / 2);
      break;
    case 'r': // right (horizontal)
    case 'right': // right (horizontal)
      dx[i] = maxx - (bbox.x + bbox.width);
      break;
    case 't': // top (vertical)
    case 'top': // top (vertical)
      dy[i] = miny - bbox.y;
      break;
    case 'm': // middle (vertical)
    case 'middle': // middle (vertical)
      dy[i] = (miny + maxy) / 2 - (bbox.y + bbox.height / 2);
      break;
    case 'b': // bottom (vertical)
    case 'bottom': // bottom (vertical)
      dy[i] = maxy - (bbox.y + bbox.height);
      break;
    }
  }
  moveSelectedElements(dx, dy);
};

/**
* Removes all selected elements from the DOM and adds the change to the
* history stack.
* @function module:selected-elem.SvgCanvas#deleteSelectedElements
* @fires module:selected-elem.SvgCanvas#event:changed
* @returns {void}
*/
export const deleteSelectedElements = function () {
  const selectedElements = elementContext_.getSelectedElements();
  const batchCmd = new BatchCommand('Delete Elements');
  const len = selectedElements.length;
  const selectedCopy = []; // selectedElements is being deleted

  for (let i = 0; i < len; ++i) {
    const selected = selectedElements[i];
    if (isNullish(selected)) { break; }

    let parent = selected.parentNode;
    let t = selected;

    // this will unselect the element and remove the selectedOutline
    elementContext_.gettingSelectorManager().releaseSelector(t);

    // Remove the path if present.
    pathModule.removePath_(t.id);

    // Get the parent if it's a single-child anchor
    if (parent.tagName === 'a' && parent.childNodes.length === 1) {
      t = parent;
      parent = parent.parentNode;
    }

    const { nextSibling } = t;
    t.remove();
    const elem = t;
    selectedCopy.push(selected); // for the copy
    batchCmd.addSubCommand(new RemoveElementCommand(elem, nextSibling, parent));
  }
  elementContext_.getCanvas().setEmptySelectedElements();

  if (!batchCmd.isEmpty()) { elementContext_.addCommandToHistory(batchCmd); }
  elementContext_.call('changed', selectedCopy);
  elementContext_.clearSelection();
};

/**
* Remembers the current selected elements on the clipboard.
* @function module:selected-elem.SvgCanvas#copySelectedElements
* @returns {void}
*/
export const copySelectedElements = function () {
  const selectedElements = elementContext_.getSelectedElements();
  const data =
    JSON.stringify(selectedElements.map((x) => elementContext_.getJsonFromSvgElement(x)));
  // Use sessionStorage for the clipboard data.
  sessionStorage.setItem(elementContext_.getClipboardID(), data);
  elementContext_.flashStorage();

  // Context menu might not exist (it is provided by editor.js).
  const canvMenu = document.getElementById('se-cmenu_canvas');
  canvMenu.setAttribute('enablemenuitems', '#paste,#paste_in_place');
};

/**
* Wraps all the selected elements in a group (`g`) element.
* @function module:selected-elem.SvgCanvas#groupSelectedElements
* @param {"a"|"g"} [type="g"] - type of element to group into, defaults to `<g>`
* @param {string} [urlArg]
* @returns {void}
*/
export const groupSelectedElements = function (type, urlArg) {
  const selectedElements = elementContext_.getSelectedElements();
  if (!type) { type = 'g'; }
  let cmdStr = '';
  let url;

  switch (type) {
  case 'a': {
    cmdStr = 'Make hyperlink';
    url = urlArg || '';
    break;
  } default: {
    type = 'g';
    cmdStr = 'Group Elements';
    break;
  }
  }

  const batchCmd = new BatchCommand(cmdStr);

  // create and insert the group element
  const g = elementContext_.addSVGElementFromJson({
    element: type,
    attr: {
      id: elementContext_.getNextId()
    }
  });
  if (type === 'a') {
    setHref(g, url);
  }
  batchCmd.addSubCommand(new InsertElementCommand(g));

  // now move all children into the group
  let i = selectedElements.length;
  while (i--) {
    let elem = selectedElements[i];
    if (isNullish(elem)) { continue; }

    if (elem.parentNode.tagName === 'a' && elem.parentNode.childNodes.length === 1) {
      elem = elem.parentNode;
    }

    const oldNextSibling = elem.nextSibling;
    const oldParent = elem.parentNode;
    g.append(elem);
    batchCmd.addSubCommand(new MoveElementCommand(elem, oldNextSibling, oldParent));
  }
  if (!batchCmd.isEmpty()) { elementContext_.addCommandToHistory(batchCmd); }

  // update selection
  elementContext_.selectOnly([ g ], true);
};

/**
* Pushes all appropriate parent group properties down to its children, then
* removes them from the group.
* @function module:selected-elem.SvgCanvas#pushGroupProperty
* @param {SVGAElement|SVGGElement} g
* @param {boolean} undoable
* @returns {BatchCommand|void}
*/
export const pushGroupProperty = function (g, undoable) {
  const children = g.childNodes;
  const len = children.length;
  const xform = g.getAttribute('transform');

  const glist = g.transform.baseVal;
  const m = transformListToTransform(glist).matrix;

  const batchCmd = new BatchCommand('Push group properties');

  // TODO: get all fill/stroke properties from the group that we are about to destroy
  // "fill", "fill-opacity", "fill-rule", "stroke", "stroke-dasharray", "stroke-dashoffset",
  // "stroke-linecap", "stroke-linejoin", "stroke-miterlimit", "stroke-opacity",
  // "stroke-width"
  // and then for each child, if they do not have the attribute (or the value is 'inherit')
  // then set the child's attribute

  const gangle = getRotationAngle(g);

  const gattrs = {
    filter: g.getAttribute('filter'),
    opacity: g.getAttribute('opacity')
  };
  let gfilter; let gblur; let changes;
  const drawing = elementContext_.getDrawing();

  for (let i = 0; i < len; i++) {
    const elem = children[i];

    if (elem.nodeType !== 1) { continue; }

    if (gattrs.opacity !== null && gattrs.opacity !== 1) {
      // const c_opac = elem.getAttribute('opacity') || 1;
      const newOpac = Math.round((elem.getAttribute('opacity') || 1) * gattrs.opacity * 100) / 100;
      elementContext_.changeSelectedAttribute('opacity', newOpac, [ elem ]);
    }

    if (gattrs.filter) {
      let cblur = elementContext_.getCanvas().getBlur(elem);
      const origCblur = cblur;
      if (!gblur) { gblur = elementContext_.getCanvas().getBlur(g); }
      if (cblur) {
        // Is this formula correct?
        cblur = Number(gblur) + Number(cblur);
      } else if (cblur === 0) {
        cblur = gblur;
      }

      // If child has no current filter, get group's filter or clone it.
      if (!origCblur) {
        // Set group's filter to use first child's ID
        if (!gfilter) {
          gfilter = getRefElem(gattrs.filter);
        } else {
          // Clone the group's filter
          gfilter = drawing.copyElem(gfilter);
          findDefs().append(gfilter);

          // const filterElem = getRefElem(gfilter);
          const blurElem = getFeGaussianBlur(gfilter);
          // Change this in future for different filters
          const suffix = (blurElem?.tagName === 'feGaussianBlur') ? 'blur' : 'filter';
          gfilter.id = elem.id + '_' + suffix;
          elementContext_.changeSelectedAttribute('filter', 'url(#' + gfilter.id + ')', [ elem ]);
        }
      } else {
        gfilter = getRefElem(elem.getAttribute('filter'));
      }
      // const filterElem = getRefElem(gfilter);
      const blurElem = getFeGaussianBlur(gfilter);

      // Update blur value
      if (cblur) {
        elementContext_.changeSelectedAttribute('stdDeviation', cblur, [ blurElem ]);
        elementContext_.getCanvas().setBlurOffsets(gfilter, cblur);
      }
    }

    let chtlist = elem.transform?.baseVal;

    // Don't process gradient transforms
    if (elem.tagName.includes('Gradient')) { chtlist = null; }

    // Hopefully not a problem to add this. Necessary for elements like <desc/>
    if (!chtlist) { continue; }

    // Apparently <defs> can get get a transformlist, but we don't want it to have one!
    if (elem.tagName === 'defs') { continue; }

    if (glist.numberOfItems) {
      // TODO: if the group's transform is just a rotate, we can always transfer the
      // rotate() down to the children (collapsing consecutive rotates and factoring
      // out any translates)
      if (gangle && glist.numberOfItems === 1) {
        // [Rg] [Rc] [Mc]
        // we want [Tr] [Rc2] [Mc] where:
        //  - [Rc2] is at the child's current center but has the
        // sum of the group and child's rotation angles
        //  - [Tr] is the equivalent translation that this child
        // undergoes if the group wasn't there

        // [Tr] = [Rg] [Rc] [Rc2_inv]

        // get group's rotation matrix (Rg)
        const rgm = glist.getItem(0).matrix;

        // get child's rotation matrix (Rc)
        let rcm = elementContext_.getSVGRoot().createSVGMatrix();
        const cangle = getRotationAngle(elem);
        if (cangle) {
          rcm = chtlist.getItem(0).matrix;
        }

        // get child's old center of rotation
        const cbox = utilsGetBBox(elem);
        const ceqm = transformListToTransform(chtlist).matrix;
        const coldc = transformPoint(cbox.x + cbox.width / 2, cbox.y + cbox.height / 2, ceqm);

        // sum group and child's angles
        const sangle = gangle + cangle;

        // get child's rotation at the old center (Rc2_inv)
        const r2 = elementContext_.getSVGRoot().createSVGTransform();
        r2.setRotate(sangle, coldc.x, coldc.y);

        // calculate equivalent translate
        const trm = matrixMultiply(rgm, rcm, r2.matrix.inverse());

        // set up tlist
        if (cangle) {
          chtlist.removeItem(0);
        }

        if (sangle) {
          if (chtlist.numberOfItems) {
            chtlist.insertItemBefore(r2, 0);
          } else {
            chtlist.appendItem(r2);
          }
        }

        if (trm.e || trm.f) {
          const tr = elementContext_.getSVGRoot().createSVGTransform();
          tr.setTranslate(trm.e, trm.f);
          if (chtlist.numberOfItems) {
            chtlist.insertItemBefore(tr, 0);
          } else {
            chtlist.appendItem(tr);
          }
        }
      } else { // more complicated than just a rotate
        // transfer the group's transform down to each child and then
        // call recalculateDimensions()
        const oldxform = elem.getAttribute('transform');
        changes = {};
        changes.transform = oldxform || '';

        const newxform = elementContext_.getSVGRoot().createSVGTransform();

        // [ gm ] [ chm ] = [ chm ] [ gm' ]
        // [ gm' ] = [ chmInv ] [ gm ] [ chm ]
        const chm = transformListToTransform(chtlist).matrix;
        const chmInv = chm.inverse();
        const gm = matrixMultiply(chmInv, m, chm);
        newxform.setMatrix(gm);
        chtlist.appendItem(newxform);
      }
      const cmd = recalculateDimensions(elem);
      if (cmd) { batchCmd.addSubCommand(cmd); }
    }
  }

  // remove transform and make it undo-able
  if (xform) {
    changes = {};
    changes.transform = xform;
    g.setAttribute('transform', '');
    g.removeAttribute('transform');
    batchCmd.addSubCommand(new ChangeElementCommand(g, changes));
  }

  if (undoable && !batchCmd.isEmpty()) {
    return batchCmd;
  }
  return undefined;
};

/**
* Converts selected/given `<use>` or child SVG element to a group.
* @function module:selected-elem.SvgCanvas#convertToGroup
* @param {Element} elem
* @fires module:selected-elem.SvgCanvas#event:selected
* @returns {void}
*/
export const convertToGroup = function (elem) {
  const selectedElements = elementContext_.getSelectedElements();
  if (!elem) {
    elem = selectedElements[0];
  }
  const $elem = elem;
  const batchCmd = new BatchCommand();
  let ts;
  const dataStorage = elementContext_.getDataStorage();
  if (dataStorage.has($elem, 'gsvg')) {
    // Use the gsvg as the new group
    const svg = elem.firstChild;
    const pt = {
      x: Number(svg.getAttribute('x')),
      y: Number(svg.getAttribute('y'))
    };

    // $(elem.firstChild.firstChild).unwrap();
    const firstChild = elem.firstChild.firstChild;
    if (firstChild) {
      // eslint-disable-next-line no-unsanitized/property
      firstChild.outerHTML = firstChild.innerHTML;
    }
    dataStorage.remove(elem, 'gsvg');

    const tlist = elem.transform.baseVal;
    const xform = elementContext_.getSVGRoot().createSVGTransform();
    xform.setTranslate(pt.x, pt.y);
    tlist.appendItem(xform);
    recalculateDimensions(elem);
    elementContext_.call('selected', [ elem ]);
  } else if (dataStorage.has($elem, 'symbol')) {
    elem = dataStorage.get($elem, 'symbol');

    ts = $elem.getAttribute('transform');
    const pos = {
      x: Number($elem.getAttribute('x')),
      y: Number($elem.getAttribute('y'))
    };

    const vb = elem.getAttribute('viewBox');

    if (vb) {
      const nums = vb.split(' ');
      pos.x -= Number(nums[0]);
      pos.y -= Number(nums[1]);
    }

    // Not ideal, but works
    ts += ' translate(' + (pos.x || 0) + ',' + (pos.y || 0) + ')';

    const prev = $elem.previousElementSibling;

    // Remove <use> element
    batchCmd.addSubCommand(new RemoveElementCommand($elem, $elem.nextElementSibling, $elem.parentNode));
    $elem.remove();

    // See if other elements reference this symbol
    const svgcontent = elementContext_.getSVGContent();
    // const hasMore = svgcontent.querySelectorAll('use:data(symbol)').length;
    // @todo review this logic
    const hasMore = svgcontent.querySelectorAll('use').length;

    const g = elementContext_.getDOMDocument().createElementNS(NS.SVG, 'g');
    const childs = elem.childNodes;

    let i;
    for (i = 0; i < childs.length; i++) {
      g.append(childs[i].cloneNode(true));
    }

    // Duplicate the gradients for Gecko, since they weren't included in the <symbol>
    if (isGecko()) {
      const svgElement = findDefs();
      const gradients = svgElement.querySelectorAll('linearGradient,radialGradient,pattern');
      for (let i = 0, im = gradients.length; im > i; i++) {
        g.appendChild(gradients[i].cloneNode(true));
      }
    }

    if (ts) {
      g.setAttribute('transform', ts);
    }

    const parent = elem.parentNode;

    elementContext_.uniquifyElems(g);

    // Put the dupe gradients back into <defs> (after uniquifying them)
    if (isGecko()) {
      const svgElement = findDefs();
      const elements = g.querySelectorAll('linearGradient,radialGradient,pattern');
      for (let i = 0, im = elements.length; im > i; i++) {
        svgElement.appendChild(elements[i]);
      }
    }

    // now give the g itself a new id
    g.id = elementContext_.getNextId();

    prev.after(g);

    if (parent) {
      if (!hasMore) {
        // remove symbol/svg element
        const { nextSibling } = elem;
        elem.remove();
        batchCmd.addSubCommand(new RemoveElementCommand(elem, nextSibling, parent));
      }
      batchCmd.addSubCommand(new InsertElementCommand(g));
    }

    elementContext_.setUseData(g);

    if (isGecko()) {
      elementContext_.convertGradients(findDefs());
    } else {
      elementContext_.convertGradients(g);
    }

    // recalculate dimensions on the top-level children so that unnecessary transforms
    // are removed
    walkTreePost(g, function (n) {
      try {
        recalculateDimensions(n);
      } catch (e) {
        console.error(e);
      }
    });

    // Give ID for any visible element missing one
    const visElems = g.querySelectorAll(elementContext_.getVisElems());
    Array.prototype.forEach.call(visElems, function (el) {
      if (!el.id) { el.id = elementContext_.getNextId(); }
    });

    elementContext_.selectOnly([ g ]);

    const cm = pushGroupProperty(g, true);
    if (cm) {
      batchCmd.addSubCommand(cm);
    }

    elementContext_.addCommandToHistory(batchCmd);
  } else {
    console.warn('Unexpected element to ungroup:', elem);
  }
};

/**
* Unwraps all the elements in a selected group (`g`) element. This requires
* significant recalculations to apply group's transforms, etc. to its children.
* @function module:selected-elem.SvgCanvas#ungroupSelectedElement
* @returns {void}
*/
export const ungroupSelectedElement = function () {
  const selectedElements = elementContext_.getSelectedElements();
  const dataStorage = elementContext_.getDataStorage();
  let g = selectedElements[0];
  if (!g) {
    return;
  }
  if (dataStorage.has(g, 'gsvg') || dataStorage.has(g, 'symbol')) {
    // Is svg, so actually convert to group
    convertToGroup(g);
    return;
  }
  if (g.tagName === 'use') {
    // Somehow doesn't have data set, so retrieve
    const symbol = getElem(getHref(g).substr(1));
    dataStorage.put(g, 'symbol', symbol);
    dataStorage.put(g, 'ref', symbol);
    convertToGroup(g);
    return;
  }
  const parentsA = getParents(g.parentNode, 'a');
  if (parentsA?.length) {
    g = parentsA[0];
  }

  // Look for parent "a"
  if (g.tagName === 'g' || g.tagName === 'a') {
    const batchCmd = new BatchCommand('Ungroup Elements');
    const cmd = pushGroupProperty(g, true);
    if (cmd) { batchCmd.addSubCommand(cmd); }

    const parent = g.parentNode;
    const anchor = g.nextSibling;
    const children = new Array(g.childNodes.length);

    let i = 0;
    while (g.firstChild) {
      const elem = g.firstChild;
      const oldNextSibling = elem.nextSibling;
      const oldParent = elem.parentNode;

      // Remove child title elements
      if (elem.tagName === 'title') {
        const { nextSibling } = elem;
        batchCmd.addSubCommand(new RemoveElementCommand(elem, nextSibling, oldParent));
        elem.remove();
        continue;
      }

      if (anchor) {
        anchor.before(elem);
      } else {
        g.after(elem);
      }
      children[i++] = elem;
      batchCmd.addSubCommand(new MoveElementCommand(elem, oldNextSibling, oldParent));
    }

    // remove the group from the selection
    elementContext_.clearSelection();

    // delete the group element (but make undo-able)
    const gNextSibling = g.nextSibling;
    g.remove();
    batchCmd.addSubCommand(new RemoveElementCommand(g, gNextSibling, parent));

    if (!batchCmd.isEmpty()) { elementContext_.addCommandToHistory(batchCmd); }

    // update selection
    elementContext_.addToSelection(children);
  }
};
/**
* Updates the editor canvas width/height/position after a zoom has occurred.
* @function module:svgcanvas.SvgCanvas#updateCanvas
* @param {Float} w - Float with the new width
* @param {Float} h - Float with the new height
* @fires module:svgcanvas.SvgCanvas#event:ext_canvasUpdated
* @returns {module:svgcanvas.CanvasInfo}
*/
export const updateCanvas = function (w, h) {
  elementContext_.getSVGRoot().setAttribute('width', w);
  elementContext_.getSVGRoot().setAttribute('height', h);
  const currentZoom = elementContext_.getCurrentZoom();
  const bg = document.getElementById('canvasBackground');
  const oldX = Number(elementContext_.getSVGContent().getAttribute('x'));
  const oldY = Number(elementContext_.getSVGContent().getAttribute('y'));
  const x = ((w - this.contentW * currentZoom) / 2);
  const y = ((h - this.contentH * currentZoom) / 2);

  assignAttributes(elementContext_.getSVGContent(), {
    width: this.contentW * currentZoom,
    height: this.contentH * currentZoom,
    x,
    y,
    viewBox: '0 0 ' + this.contentW + ' ' + this.contentH
  });

  assignAttributes(bg, {
    width: elementContext_.getSVGContent().getAttribute('width'),
    height: elementContext_.getSVGContent().getAttribute('height'),
    x,
    y
  });

  const bgImg = getElem('background_image');
  if (bgImg) {
    assignAttributes(bgImg, {
      width: '100%',
      height: '100%'
    });
  }

  elementContext_.getCanvas().selectorManager.selectorParentGroup.setAttribute('transform', 'translate(' + x + ',' + y + ')');

  /**
* Invoked upon updates to the canvas.
* @event module:svgcanvas.SvgCanvas#event:ext_canvasUpdated
* @type {PlainObject}
* @property {Integer} new_x
* @property {Integer} new_y
* @property {string} old_x (Of Integer)
* @property {string} old_y (Of Integer)
* @property {Integer} d_x
* @property {Integer} d_y
*/
  elementContext_.getCanvas().runExtensions(
    'canvasUpdated',
    /**
 * @type {module:svgcanvas.SvgCanvas#event:ext_canvasUpdated}
 */
    { new_x: x, new_y: y, old_x: oldX, old_y: oldY, d_x: x - oldX, d_y: y - oldY }
  );
  return { x, y, old_x: oldX, old_y: oldY, d_x: x - oldX, d_y: y - oldY };
};
/**
* Select the next/previous element within the current layer.
* @function module:svgcanvas.SvgCanvas#cycleElement
* @param {boolean} next - true = next and false = previous element
* @fires module:svgcanvas.SvgCanvas#event:selected
* @returns {void}
*/
export const cycleElement = function (next) {
  const selectedElements = elementContext_.getSelectedElements();
  const currentGroup = elementContext_.getCurrentGroup();
  let num;
  const curElem = selectedElements[0];
  let elem = false;
  const allElems = getVisibleElements(currentGroup || elementContext_.getCanvas().getCurrentDrawing().getCurrentLayer());
  if (!allElems.length) { return; }
  if (isNullish(curElem)) {
    num = next ? allElems.length - 1 : 0;
    elem = allElems[num];
  } else {
    let i = allElems.length;
    while (i--) {
      if (allElems[i] === curElem) {
        num = next ? i - 1 : i + 1;
        if (num >= allElems.length) {
          num = 0;
        } else if (num < 0) {
          num = allElems.length - 1;
        }
        elem = allElems[num];
        break;
      }
    }
  }
  elementContext_.getCanvas().selectOnly([ elem ], true);
  elementContext_.call('selected', selectedElements);
};