Source: svgcanvas/path-actions.js

/**
 * Path functionality.
 * @module path
 * @license MIT
 *
 * @copyright 2011 Alexis Deveria, 2011 Jeff Schiller
 */

import { NS } from './namespaces.js';
import { shortFloat } from '../common/units.js';
import { ChangeElementCommand, BatchCommand } from './history.js';
import {
  transformPoint, snapToAngle, rectsIntersect,
  transformListToTransform
} from './math.js';
import {
  assignAttributes, getElem, getRotationAngle, snapToGrid, isNullish,
  getBBox as utilsGetBBox
} from './utilities.js';

let pathActionsContext_ = null;
let editorContext_ = null;
let path = null;

/**
* @function module:path-actions.init
* @param {module:path-actions.pathActionsContext_} pathActionsContext
* @returns {void}
*/
export const init = function (pathActionsContext) {
  pathActionsContext_ = pathActionsContext;
  // editorContext_ = pathActionsContext_.getEditorContext();
  // path = pathActionsContext_.getPathObj();
};

/**
 * Convert a path to one with only absolute or relative values.
 * @todo move to pathActions.js
 * @function module:path.convertPath
 * @param {SVGPathElement} pth - the path to convert
 * @param {boolean} toRel - true of convert to relative
 * @returns {string}
 */
export const convertPath = function (pth, toRel) {
  const { pathSegList } = pth;
  const len = pathSegList.numberOfItems;
  let curx = 0; let cury = 0;
  let d = '';
  let lastM = null;

  for (let i = 0; i < len; ++i) {
    const seg = pathSegList.getItem(i);
    // if these properties are not in the segment, set them to zero
    let x = seg.x || 0;
    let y = seg.y || 0;
    let x1 = seg.x1 || 0;
    let y1 = seg.y1 || 0;
    let x2 = seg.x2 || 0;
    let y2 = seg.y2 || 0;

    // const type = seg.pathSegType;
    // const pathMap = pathActionsContext_.getPathMap();
    // let letter = pathMap[type][toRel ? 'toLowerCase' : 'toUpperCase']();
    let letter = seg.pathSegTypeAsLetter;

    switch (letter) {
    case 'z': // z,Z closepath (Z/z)
    case 'Z':
      d += 'z';
      if (lastM && !toRel) {
        curx = lastM[0];
        cury = lastM[1];
      }
      break;
    case 'H': // absolute horizontal line (H)
      x -= curx;
      // Fallthrough
    case 'h': // relative horizontal line (h)
      if (toRel) {
        y = 0;
        curx += x;
        letter = 'l';
      } else {
        y = cury;
        x += curx;
        curx = x;
        letter = 'L';
      }
      // Convert to "line" for easier editing
      d += pathDSegment(letter, [ [ x, y ] ]);
      break;
    case 'V': // absolute vertical line (V)
      y -= cury;
      // Fallthrough
    case 'v': // relative vertical line (v)
      if (toRel) {
        x = 0;
        cury += y;
        letter = 'l';
      } else {
        x = curx;
        y += cury;
        cury = y;
        letter = 'L';
      }
      // Convert to "line" for easier editing
      d += pathDSegment(letter, [ [ x, y ] ]);
      break;
    case 'M': // absolute move (M)
    case 'L': // absolute line (L)
    case 'T': // absolute smooth quad (T)
      x -= curx;
      y -= cury;
      // Fallthrough
    case 'l': // relative line (l)
    case 'm': // relative move (m)
    case 't': // relative smooth quad (t)
      if (toRel) {
        curx += x;
        cury += y;
        letter = letter.toLowerCase();
      } else {
        x += curx;
        y += cury;
        curx = x;
        cury = y;
        letter = letter.toUpperCase();
      }
      if (letter === 'm' || letter === 'M') { lastM = [ curx, cury ]; }

      d += pathDSegment(letter, [ [ x, y ] ]);
      break;
    case 'C': // absolute cubic (C)
      x -= curx; x1 -= curx; x2 -= curx;
      y -= cury; y1 -= cury; y2 -= cury;
      // Fallthrough
    case 'c': // relative cubic (c)
      if (toRel) {
        curx += x;
        cury += y;
        letter = 'c';
      } else {
        x += curx; x1 += curx; x2 += curx;
        y += cury; y1 += cury; y2 += cury;
        curx = x;
        cury = y;
        letter = 'C';
      }
      d += pathDSegment(letter, [ [ x1, y1 ], [ x2, y2 ], [ x, y ] ]);
      break;
    case 'Q': // absolute quad (Q)
      x -= curx; x1 -= curx;
      y -= cury; y1 -= cury;
      // Fallthrough
    case 'q': // relative quad (q)
      if (toRel) {
        curx += x;
        cury += y;
        letter = 'q';
      } else {
        x += curx; x1 += curx;
        y += cury; y1 += cury;
        curx = x;
        cury = y;
        letter = 'Q';
      }
      d += pathDSegment(letter, [ [ x1, y1 ], [ x, y ] ]);
      break;
    case 'A':
      x -= curx;
      y -= cury;
      // fallthrough
    case 'a': // relative elliptical arc (a)
      if (toRel) {
        curx += x;
        cury += y;
        letter = 'a';
      } else {
        x += curx;
        y += cury;
        curx = x;
        cury = y;
        letter = 'A';
      }
      d += pathDSegment(letter, [ [ seg.r1, seg.r2 ] ], [
        seg.angle,
        (seg.largeArcFlag ? 1 : 0),
        (seg.sweepFlag ? 1 : 0)
      ], [ x, y ]);
      break;
    case 'S': // absolute smooth cubic (S)
      x -= curx; x2 -= curx;
      y -= cury; y2 -= cury;
      // Fallthrough
    case 's': // relative smooth cubic (s)
      if (toRel) {
        curx += x;
        cury += y;
        letter = 's';
      } else {
        x += curx; x2 += curx;
        y += cury; y2 += cury;
        curx = x;
        cury = y;
        letter = 'S';
      }
      d += pathDSegment(letter, [ [ x2, y2 ], [ x, y ] ]);
      break;
    } // switch on path segment type
  } // for each segment
  return d;
};

/**
 * TODO: refactor callers in `convertPath` to use `getPathDFromSegments` instead of this function.
 * Legacy code refactored from `svgcanvas.pathActions.convertPath`.
 * @param {string} letter - path segment command (letter in potentially either case from {@link module:path.pathMap}; see [SVGPathSeg#pathSegTypeAsLetter]{@link https://www.w3.org/TR/SVG/single-page.html#paths-__svg__SVGPathSeg__pathSegTypeAsLetter})
 * @param {GenericArray<GenericArray<Integer>>} points - x,y points
 * @param {GenericArray<GenericArray<Integer>>} [morePoints] - x,y points
 * @param {Integer[]} [lastPoint] - x,y point
 * @returns {string}
 */
function pathDSegment (letter, points, morePoints, lastPoint) {
  points.forEach(function(pnt, i){
    points[i] = shortFloat(pnt);
  });
  let segment = letter + points.join(' ');
  if (morePoints) {
    segment += ' ' + morePoints.join(' ');
  }
  if (lastPoint) {
    segment += ' ' + shortFloat(lastPoint);
  }
  return segment;
}

/**
* Group: Path edit functions.
* Functions relating to editing path elements.
* @namespace {PlainObject} pathActions
* @memberof module:path
*/
export const pathActionsMethod = (function () {
  let subpath = false;
  let newPoint; let firstCtrl;

  let currentPath = null;
  let hasMoved = false;
  // No `editorContext_` yet but should be ok as is `null` by default
  // editorContext_.setDrawnPath(null);

  /**
  * This function converts a polyline (created by the fh_path tool) into
  * a path element and coverts every three line segments into a single bezier
  * curve in an attempt to smooth out the free-hand.
  * @function smoothPolylineIntoPath
  * @param {Element} element
  * @returns {Element}
  */
  const smoothPolylineIntoPath = function (element) {
    let i;
    const { points } = element;
    const N = points.numberOfItems;
    if (N >= 4) {
      // loop through every 3 points and convert to a cubic bezier curve segment
      //
      // NOTE: this is cheating, it means that every 3 points has the potential to
      // be a corner instead of treating each point in an equal manner. In general,
      // this technique does not look that good.
      //
      // I am open to better ideas!
      //
      // Reading:
      // - http://www.efg2.com/Lab/Graphics/Jean-YvesQueinecBezierCurves.htm
      // - https://www.codeproject.com/KB/graphics/BezierSpline.aspx?msg=2956963
      // - https://www.ian-ko.com/ET_GeoWizards/UserGuide/smooth.htm
      // - https://www.cs.mtu.edu/~shene/COURSES/cs3621/NOTES/spline/Bezier/bezier-der.html
      let curpos = points.getItem(0); let prevCtlPt = null;
      let d = [];
      d.push([ 'M', curpos.x, ',', curpos.y, ' C' ].join(''));
      for (i = 1; i <= (N - 4); i += 3) {
        let ct1 = points.getItem(i);
        const ct2 = points.getItem(i + 1);
        const end = points.getItem(i + 2);

        // if the previous segment had a control point, we want to smooth out
        // the control points on both sides
        if (prevCtlPt) {
          const newpts = pathActionsContext_.smoothControlPoints(prevCtlPt, ct1, curpos);
          if (newpts && newpts.length === 2) {
            const prevArr = d[d.length - 1].split(',');
            prevArr[2] = newpts[0].x;
            prevArr[3] = newpts[0].y;
            d[d.length - 1] = prevArr.join(',');
            ct1 = newpts[1];
          }
        }

        d.push([ ct1.x, ct1.y, ct2.x, ct2.y, end.x, end.y ].join(','));

        curpos = end;
        prevCtlPt = ct2;
      }
      // handle remaining line segments
      d.push('L');
      while (i < N) {
        const pt = points.getItem(i);
        d.push([ pt.x, pt.y ].join(','));
        i++;
      }
      d = d.join(' ');

      // create new path element
      editorContext_ = pathActionsContext_.getEditorContext();
      element = editorContext_.addSVGElementFromJson({
        element: 'path',
        curStyles: true,
        attr: {
          id: editorContext_.getId(),
          d,
          fill: 'none'
        }
      });
      // No need to call "changed", as this is already done under mouseUp
    }
    return element;
  };

  return (/** @lends module:path.pathActions */ {
    /**
    * @param {MouseEvent} evt
    * @param {Element} mouseTarget
    * @param {Float} startX
    * @param {Float} startY
    * @returns {boolean|void}
    */
    mouseDown (evt, mouseTarget, startX, startY) {
      let id;
      editorContext_ = pathActionsContext_.getEditorContext();
      if (editorContext_.getCurrentMode() === 'path') {
        let mouseX = startX; // Was this meant to work with the other `mouseX`? (was defined globally so adding `let` to at least avoid a global)
        let mouseY = startY; // Was this meant to work with the other `mouseY`? (was defined globally so adding `let` to at least avoid a global)

        const currentZoom = editorContext_.getCurrentZoom();
        let x = mouseX / currentZoom;
        let y = mouseY / currentZoom;
        let stretchy = getElem('path_stretch_line');
        newPoint = [ x, y ];

        if (editorContext_.getGridSnapping()) {
          x = snapToGrid(x);
          y = snapToGrid(y);
          mouseX = snapToGrid(mouseX);
          mouseY = snapToGrid(mouseY);
        }

        if (!stretchy) {
          stretchy = document.createElementNS(NS.SVG, 'path');
          assignAttributes(stretchy, {
            id: 'path_stretch_line',
            stroke: '#22C',
            'stroke-width': '0.5',
            fill: 'none'
          });
          getElem('selectorParentGroup').append(stretchy);
        }
        stretchy.setAttribute('display', 'inline');

        let keep = null;
        let index;
        // if pts array is empty, create path element with M at current point
        const drawnPath = editorContext_.getDrawnPath();
        if (!drawnPath) {
          const dAttr = 'M' + x + ',' + y + ' '; // Was this meant to work with the other `dAttr`? (was defined globally so adding `var` to at least avoid a global)
          /* drawnPath = */ editorContext_.setDrawnPath(editorContext_.addSVGElementFromJson({
            element: 'path',
            curStyles: true,
            attr: {
              d: dAttr,
              id: editorContext_.getNextId(),
              opacity: editorContext_.getOpacity() / 2
            }
          }));
          // set stretchy line to first point
          stretchy.setAttribute('d', [ 'M', mouseX, mouseY, mouseX, mouseY ].join(' '));
          index = subpath ? path.segs.length : 0;
          pathActionsContext_.addPointGrip(index, mouseX, mouseY);
        } else {
          // determine if we clicked on an existing point
          const seglist = drawnPath.pathSegList;
          let i = seglist.numberOfItems;
          const FUZZ = 6 / currentZoom;
          let clickOnPoint = false;
          while (i) {
            i--;
            const item = seglist.getItem(i);
            const px = item.x; const py = item.y;
            // found a matching point
            if (x >= (px - FUZZ) && x <= (px + FUZZ) &&
              y >= (py - FUZZ) && y <= (py + FUZZ)
            ) {
              clickOnPoint = true;
              break;
            }
          }

          // get path element that we are in the process of creating
          id = editorContext_.getId();

          // Remove previous path object if previously created
          pathActionsContext_.removePath_(id);

          const newpath = getElem(id);
          let newseg;
          let sSeg;
          const len = seglist.numberOfItems;
          // if we clicked on an existing point, then we are done this path, commit it
          // (i, i+1) are the x,y that were clicked on
          if (clickOnPoint) {
            // if clicked on any other point but the first OR
            // the first point was clicked on and there are less than 3 points
            // then leave the path open
            // otherwise, close the path
            if (i <= 1 && len >= 2) {
              // Create end segment
              const absX = seglist.getItem(0).x;
              const absY = seglist.getItem(0).y;

              sSeg = stretchy.pathSegList.getItem(1);
              newseg = sSeg.pathSegType === 4
                ? drawnPath.createSVGPathSegLinetoAbs(absX, absY)
                : drawnPath.createSVGPathSegCurvetoCubicAbs(absX, absY, sSeg.x1 / currentZoom, sSeg.y1 / currentZoom, absX, absY);

              const endseg = drawnPath.createSVGPathSegClosePath();
              seglist.appendItem(newseg);
              seglist.appendItem(endseg);
            } else if (len < 3) {
              keep = false;
              return keep;
            }
            stretchy.remove();

            // This will signal to commit the path
            // const element = newpath; // Other event handlers define own `element`, so this was probably not meant to interact with them or one which shares state (as there were none); I therefore adding a missing `var` to avoid a global
            /* drawnPath = */ editorContext_.setDrawnPath(null);
            editorContext_.setStarted(false);

            if (subpath) {
              if (path.matrix) {
                editorContext_.remapElement(newpath, {}, path.matrix.inverse());
              }

              const newD = newpath.getAttribute('d');
              const origD = path.elem.getAttribute('d');
              path.elem.setAttribute('d', origD + newD);
              newpath.parentNode.removeChild(newpath);
              if (path.matrix) {
                pathActionsContext_.recalcRotatedPath();
              }
              pathActionsMethod.toEditMode(path.elem);
              path.selectPt();
              return false;
            }
          // else, create a new point, update path element
          } else {
            // Checks if current target or parents are #svgcontent
            if (!(editorContext_.getContainer() !== editorContext_.getMouseTarget(evt) && editorContext_.getContainer().contains(
              editorContext_.getMouseTarget(evt)
            ))) {
              // Clicked outside canvas, so don't make point
              return false;
            }

            const num = drawnPath.pathSegList.numberOfItems;
            const last = drawnPath.pathSegList.getItem(num - 1);
            const lastx = last.x; const lasty = last.y;

            if (evt.shiftKey) {
              const xya = snapToAngle(lastx, lasty, x, y);
              ({ x, y } = xya);
            }

            // Use the segment defined by stretchy
            sSeg = stretchy.pathSegList.getItem(1);
            newseg = sSeg.pathSegType === 4
              ? drawnPath.createSVGPathSegLinetoAbs(editorContext_.round(x), editorContext_.round(y))
              : drawnPath.createSVGPathSegCurvetoCubicAbs(
                editorContext_.round(x),
                editorContext_.round(y),
                sSeg.x1 / currentZoom,
                sSeg.y1 / currentZoom,
                sSeg.x2 / currentZoom,
                sSeg.y2 / currentZoom
              );

            drawnPath.pathSegList.appendItem(newseg);

            x *= currentZoom;
            y *= currentZoom;

            // set stretchy line to latest point
            stretchy.setAttribute('d', [ 'M', x, y, x, y ].join(' '));
            index = num;
            if (subpath) { index += path.segs.length; }
            pathActionsContext_.addPointGrip(index, x, y);
          }
          // keep = true;
        }

        return undefined;
      }

      // TODO: Make sure currentPath isn't null at this point
      if (!path) { return undefined; }

      path.storeD();

      ({ id } = evt.target);
      let curPt;
      if (id.substr(0, 14) === 'pathpointgrip_') {
        // Select this point
        curPt = path.cur_pt = Number.parseInt(id.substr(14));
        path.dragging = [ startX, startY ];
        const seg = path.segs[curPt];

        // only clear selection if shift is not pressed (otherwise, add
        // node to selection)
        if (!evt.shiftKey) {
          if (path.selected_pts.length <= 1 || !seg.selected) {
            path.clearSelection();
          }
          path.addPtsToSelection(curPt);
        } else if (seg.selected) {
          path.removePtFromSelection(curPt);
        } else {
          path.addPtsToSelection(curPt);
        }
      } else if (id.startsWith('ctrlpointgrip_')) {
        path.dragging = [ startX, startY ];

        const parts = id.split('_')[1].split('c');
        curPt = Number(parts[0]);
        const ctrlNum = Number(parts[1]);
        path.selectPt(curPt, ctrlNum);
      }

      // Start selection box
      if (!path.dragging) {
        let rubberBox = editorContext_.getRubberBox();
        if (isNullish(rubberBox)) {
          rubberBox = editorContext_.setRubberBox(
            editorContext_.selectorManager.getRubberBandBox()
          );
        }
        const currentZoom = editorContext_.getCurrentZoom();
        assignAttributes(rubberBox, {
          x: startX * currentZoom,
          y: startY * currentZoom,
          width: 0,
          height: 0,
          display: 'inline'
        }, 100);
      }
      return undefined;
    },
    /**
    * @param {Float} mouseX
    * @param {Float} mouseY
    * @returns {void}
    */
    mouseMove (mouseX, mouseY) {
      editorContext_ = pathActionsContext_.getEditorContext();
      const currentZoom = editorContext_.getCurrentZoom();
      hasMoved = true;
      const drawnPath = editorContext_.getDrawnPath();
      if (editorContext_.getCurrentMode() === 'path') {
        if (!drawnPath) { return; }
        const seglist = drawnPath.pathSegList;
        const index = seglist.numberOfItems - 1;

        if (newPoint) {
          // First point
          // if (!index) { return; }

          // Set control points
          const pointGrip1 = pathActionsContext_.addCtrlGrip('1c1');
          const pointGrip2 = pathActionsContext_.addCtrlGrip('0c2');

          // dragging pointGrip1
          pointGrip1.setAttribute('cx', mouseX);
          pointGrip1.setAttribute('cy', mouseY);
          pointGrip1.setAttribute('display', 'inline');

          const ptX = newPoint[0];
          const ptY = newPoint[1];

          // set curve
          // const seg = seglist.getItem(index);
          const curX = mouseX / currentZoom;
          const curY = mouseY / currentZoom;
          const altX = (ptX + (ptX - curX));
          const altY = (ptY + (ptY - curY));

          pointGrip2.setAttribute('cx', altX * currentZoom);
          pointGrip2.setAttribute('cy', altY * currentZoom);
          pointGrip2.setAttribute('display', 'inline');

          const ctrlLine = pathActionsContext_.getCtrlLine(1);
          assignAttributes(ctrlLine, {
            x1: mouseX,
            y1: mouseY,
            x2: altX * currentZoom,
            y2: altY * currentZoom,
            display: 'inline'
          });

          if (index === 0) {
            firstCtrl = [ mouseX, mouseY ];
          } else {
            const last = seglist.getItem(index - 1);
            let lastX = last.x;
            let lastY = last.y;

            if (last.pathSegType === 6) {
              lastX += (lastX - last.x2);
              lastY += (lastY - last.y2);
            } else if (firstCtrl) {
              lastX = firstCtrl[0] / currentZoom;
              lastY = firstCtrl[1] / currentZoom;
            }
            pathActionsContext_.replacePathSeg(6, index, [ ptX, ptY, lastX, lastY, altX, altY ], drawnPath);
          }
        } else {
          const stretchy = getElem('path_stretch_line');
          if (stretchy) {
            const prev = seglist.getItem(index);
            if (prev.pathSegType === 6) {
              const prevX = prev.x + (prev.x - prev.x2);
              const prevY = prev.y + (prev.y - prev.y2);
              pathActionsContext_.replacePathSeg(
                6,
                1,
                [ mouseX, mouseY, prevX * currentZoom, prevY * currentZoom, mouseX, mouseY ],
                stretchy
              );
            } else if (firstCtrl) {
              pathActionsContext_.replacePathSeg(6, 1, [ mouseX, mouseY, firstCtrl[0], firstCtrl[1], mouseX, mouseY ], stretchy);
            } else {
              pathActionsContext_.replacePathSeg(4, 1, [ mouseX, mouseY ], stretchy);
            }
          }
        }
        return;
      }
      // if we are dragging a point, let's move it
      if (path.dragging) {
        const pt = pathActionsContext_.getPointFromGrip({
          x: path.dragging[0],
          y: path.dragging[1]
        }, path);
        const mpt = pathActionsContext_.getPointFromGrip({
          x: mouseX,
          y: mouseY
        }, path);
        const diffX = mpt.x - pt.x;
        const diffY = mpt.y - pt.y;
        path.dragging = [ mouseX, mouseY ];

        if (path.dragctrl) {
          path.moveCtrl(diffX, diffY);
        } else {
          path.movePts(diffX, diffY);
        }
      } else {
        path.selected_pts = [];
        path.eachSeg(function (_i) {
          const seg = this;
          if (!seg.next && !seg.prev) { return; }

          // const {item} = seg;
          const rubberBox = editorContext_.getRubberBox();
          const rbb = rubberBox.getBBox();

          const pt = pathActionsContext_.getGripPt(seg);
          const ptBb = {
            x: pt.x,
            y: pt.y,
            width: 0,
            height: 0
          };

          const sel = rectsIntersect(rbb, ptBb);

          this.select(sel);
          // Note that addPtsToSelection is not being run
          if (sel) { path.selected_pts.push(seg.index); }
        });
      }
    },
    /**
     * @typedef module:path.keepElement
     * @type {PlainObject}
     * @property {boolean} keep
     * @property {Element} element
     */
    /**
    * @param {Event} evt
    * @param {Element} element
    * @param {Float} _mouseX
    * @param {Float} _mouseY
    * @returns {module:path.keepElement|void}
    */
    mouseUp (evt, element, _mouseX, _mouseY) {
      editorContext_ = pathActionsContext_.getEditorContext();
      const drawnPath = editorContext_.getDrawnPath();
      // Create mode
      if (editorContext_.getCurrentMode() === 'path') {
        newPoint = null;
        if (!drawnPath) {
          element = getElem(editorContext_.getId());
          editorContext_.setStarted(false);
          firstCtrl = null;
        }

        return {
          keep: true,
          element
        };
      }

      // Edit mode
      const rubberBox = editorContext_.getRubberBox();
      if (path.dragging) {
        const lastPt = path.cur_pt;

        path.dragging = false;
        path.dragctrl = false;
        path.update();

        if (hasMoved) {
          path.endChanges('Move path point(s)');
        }

        if (!evt.shiftKey && !hasMoved) {
          path.selectPt(lastPt);
        }
      } else if (rubberBox && rubberBox.getAttribute('display') !== 'none') {
        // Done with multi-node-select
        rubberBox.setAttribute('display', 'none');

        if (rubberBox.getAttribute('width') <= 2 && rubberBox.getAttribute('height') <= 2) {
          pathActionsMethod.toSelectMode(evt.target);
        }

      // else, move back to select mode
      } else {
        pathActionsMethod.toSelectMode(evt.target);
      }
      hasMoved = false;
      return undefined;
    },
    /**
    * @param {Element} element
    * @returns {void}
    */
    toEditMode (element) {
      editorContext_ = pathActionsContext_.getEditorContext();
      path = pathActionsContext_.getPath_(element);
      editorContext_.setCurrentMode('pathedit');
      editorContext_.clearSelection();
      path.setPathContext();
      path.show(true).update();
      path.oldbbox = utilsGetBBox(path.elem);
      subpath = false;
    },
    /**
    * @param {Element} elem
    * @fires module:svgcanvas.SvgCanvas#event:selected
    * @returns {void}
    */
    toSelectMode (elem) {
      editorContext_ = pathActionsContext_.getEditorContext();
      const selPath = (elem === path.elem);
      editorContext_.setCurrentMode('select');
      path.setPathContext();
      path.show(false);
      currentPath = false;
      editorContext_.clearSelection();

      if (path.matrix) {
        // Rotated, so may need to re-calculate the center
        pathActionsContext_.recalcRotatedPath();
      }

      if (selPath) {
        editorContext_.call('selected', [ elem ]);
        editorContext_.addToSelection([ elem ], true);
      }
    },
    /**
    * @param {boolean} on
    * @returns {void}
    */
    addSubPath (on) {
      editorContext_ = pathActionsContext_.getEditorContext();
      if (on) {
        // Internally we go into "path" mode, but in the UI it will
        // still appear as if in "pathedit" mode.
        editorContext_.setCurrentMode('path');
        subpath = true;
      } else {
        pathActionsMethod.clear(true);
        pathActionsMethod.toEditMode(path.elem);
      }
    },
    /**
    * @param {Element} target
    * @returns {void}
    */
    select (target) {
      editorContext_ = pathActionsContext_.getEditorContext();
      if (currentPath === target) {
        pathActionsMethod.toEditMode(target);
        editorContext_.setCurrentMode('pathedit');
      // going into pathedit mode
      } else {
        currentPath = target;
      }
    },
    /**
    * @fires module:svgcanvas.SvgCanvas#event:changed
    * @returns {void}
    */
    reorient () {
      editorContext_ = pathActionsContext_.getEditorContext();
      const elem = editorContext_.getSelectedElements()[0];
      if (!elem) { return; }
      const angl = getRotationAngle(elem);
      if (angl === 0) { return; }

      const batchCmd = new BatchCommand('Reorient path');
      const changes = {
        d: elem.getAttribute('d'),
        transform: elem.getAttribute('transform')
      };
      batchCmd.addSubCommand(new ChangeElementCommand(elem, changes));
      editorContext_.clearSelection();
      this.resetOrientation(elem);

      editorContext_.addCommandToHistory(batchCmd);

      // Set matrix to null
      pathActionsContext_.getPath_(elem).show(false).matrix = null;

      this.clear();

      editorContext_.addToSelection([ elem ], true);
      editorContext_.call('changed', editorContext_.getSelectedElements());
    },

    /**
    * @param {boolean} remove Not in use
    * @returns {void}
    */
    clear () {
      editorContext_ = pathActionsContext_.getEditorContext();
      const drawnPath = editorContext_.getDrawnPath();
      currentPath = null;
      if (drawnPath) {
        const elem = getElem(editorContext_.getId());
        const psl = getElem('path_stretch_line');
        psl.parentNode.removeChild(psl);
        elem.parentNode.removeChild(elem);
        const pathpointgripContainer = getElem('pathpointgrip_container');
        const elements = pathpointgripContainer.querySelectorAll('*');
        Array.prototype.forEach.call(elements, function(el){
          el.setAttribute('display', 'none');
        });
        firstCtrl = null;
        editorContext_.setDrawnPath(null);
        editorContext_.setStarted(false);
      } else if (editorContext_.getCurrentMode() === 'pathedit') {
        this.toSelectMode();
      }
      if (path) { path.init().show(false); }
    },
    /**
    * @param {?(Element|SVGPathElement)} pth
    * @returns {false|void}
    */
    resetOrientation (pth) {
      if (isNullish(pth) || pth.nodeName !== 'path') { return false; }
      const tlist = pth.transform.baseVal;
      const m = transformListToTransform(tlist).matrix;
      tlist.clear();
      pth.removeAttribute('transform');
      const segList = pth.pathSegList;

      // Opera/win/non-EN throws an error here.
      // TODO: Find out why!
      // Presumed fixed in Opera 10.5, so commented out for now

      // try {
      const len = segList.numberOfItems;
      // } catch(err) {
      //   const fixed_d = pathActions.convertPath(pth);
      //   pth.setAttribute('d', fixed_d);
      //   segList = pth.pathSegList;
      //   const len = segList.numberOfItems;
      // }
      // let lastX, lastY;
      for (let i = 0; i < len; ++i) {
        const seg = segList.getItem(i);
        const type = seg.pathSegType;
        if (type === 1) { continue; }
        const pts = [];
        [ '', 1, 2 ].forEach(function(n){
          const x = seg['x' + n]; const y = seg['y' + n];
          if (x !== undefined && y !== undefined) {
            const pt = transformPoint(x, y, m);
            pts.splice(pts.length, 0, pt.x, pt.y);
          }
        });
        pathActionsContext_.replacePathSeg(type, i, pts, pth);
      }

      pathActionsContext_.reorientGrads(pth, m);
      return undefined;
    },
    /**
    * @returns {void}
    */
    zoomChange () {
      editorContext_ = pathActionsContext_.getEditorContext();
      if (editorContext_.getCurrentMode() === 'pathedit') {
        path.update();
      }
    },
    /**
    * @typedef {PlainObject} module:path.NodePoint
    * @property {Float} x
    * @property {Float} y
    * @property {Integer} type
    */
    /**
    * @returns {module:path.NodePoint}
    */
    getNodePoint () {
      const selPt = path.selected_pts.length ? path.selected_pts[0] : 1;

      const seg = path.segs[selPt];
      return {
        x: seg.item.x,
        y: seg.item.y,
        type: seg.type
      };
    },
    /**
    * @param {boolean} linkPoints
    * @returns {void}
    */
    linkControlPoints (linkPoints) {
      pathActionsContext_.setLinkControlPoints(linkPoints);
    },
    /**
    * @returns {void}
    */
    clonePathNode () {
      path.storeD();

      const selPts = path.selected_pts;
      // const {segs} = path;

      let i = selPts.length;
      const nums = [];

      while (i--) {
        const pt = selPts[i];
        path.addSeg(pt);

        nums.push(pt + i);
        nums.push(pt + i + 1);
      }
      path.init().addPtsToSelection(nums);

      path.endChanges('Clone path node(s)');
    },
    /**
    * @returns {void}
    */
    opencloseSubPath () {
      const selPts = path.selected_pts;
      // Only allow one selected node for now
      if (selPts.length !== 1) { return; }

      const { elem } = path;
      const list = elem.pathSegList;

      // const len = list.numberOfItems;

      const index = selPts[0];

      let openPt = null;
      let startItem = null;

      // Check if subpath is already open
      path.eachSeg(function (i) {
        if (this.type === 2 && i <= index) {
          startItem = this.item;
        }
        if (i <= index) { return true; }
        if (this.type === 2) {
          // Found M first, so open
          openPt = i;
          return false;
        }
        if (this.type === 1) {
          // Found Z first, so closed
          openPt = false;
          return false;
        }
        return true;
      });

      if (isNullish(openPt)) {
        // Single path, so close last seg
        openPt = path.segs.length - 1;
      }

      if (openPt !== false) {
        // Close this path

        // Create a line going to the previous "M"
        const newseg = elem.createSVGPathSegLinetoAbs(startItem.x, startItem.y);

        const closer = elem.createSVGPathSegClosePath();
        if (openPt === path.segs.length - 1) {
          list.appendItem(newseg);
          list.appendItem(closer);
        } else {
          list.insertItemBefore(closer, openPt);
          list.insertItemBefore(newseg, openPt);
        }

        path.init().selectPt(openPt + 1);
        return;
      }

      // M 1,1 L 2,2 L 3,3 L 1,1 z // open at 2,2
      // M 2,2 L 3,3 L 1,1

      // M 1,1 L 2,2 L 1,1 z M 4,4 L 5,5 L6,6 L 5,5 z
      // M 1,1 L 2,2 L 1,1 z [M 4,4] L 5,5 L(M)6,6 L 5,5 z

      const seg = path.segs[index];

      if (seg.mate) {
        list.removeItem(index); // Removes last "L"
        list.removeItem(index); // Removes the "Z"
        path.init().selectPt(index - 1);
        return;
      }

      let lastM; let zSeg;

      // Find this sub-path's closing point and remove
      for (let i = 0; i < list.numberOfItems; i++) {
        const item = list.getItem(i);

        if (item.pathSegType === 2) {
          // Find the preceding M
          lastM = i;
        } else if (i === index) {
          // Remove it
          list.removeItem(lastM);
          // index--;
        } else if (item.pathSegType === 1 && index < i) {
          // Remove the closing seg of this subpath
          zSeg = i - 1;
          list.removeItem(i);
          break;
        }
      }

      let num = (index - lastM) - 1;

      while (num--) {
        list.insertItemBefore(list.getItem(lastM), zSeg);
      }

      const pt = list.getItem(lastM);

      // Make this point the new "M"
      pathActionsContext_.replacePathSeg(2, lastM, [ pt.x, pt.y ]);

      // i = index; // i is local here, so has no effect; what was the intent for this?

      path.init().selectPt(0);
    },
    /**
    * @returns {void}
    */
    deletePathNode () {
      if (!pathActionsMethod.canDeleteNodes) { return; }
      path.storeD();

      const selPts = path.selected_pts;

      let i = selPts.length;
      while (i--) {
        const pt = selPts[i];
        path.deleteSeg(pt);
      }

      // Cleanup
      const cleanup = function () {
        const segList = path.elem.pathSegList;
        let len = segList.numberOfItems;

        const remItems = function (pos, count) {
          while (count--) {
            segList.removeItem(pos);
          }
        };

        if (len <= 1) { return true; }

        while (len--) {
          const item = segList.getItem(len);
          if (item.pathSegType === 1) {
            const prev = segList.getItem(len - 1);
            const nprev = segList.getItem(len - 2);
            if (prev.pathSegType === 2) {
              remItems(len - 1, 2);
              cleanup();
              break;
            } else if (nprev.pathSegType === 2) {
              remItems(len - 2, 3);
              cleanup();
              break;
            }
          } else if (item.pathSegType === 2 && len > 0) {
            const prevType = segList.getItem(len - 1).pathSegType;
            // Path has M M
            if (prevType === 2) {
              remItems(len - 1, 1);
              cleanup();
              break;
              // Entire path ends with Z M
            } else if (prevType === 1 && segList.numberOfItems - 1 === len) {
              remItems(len, 1);
              cleanup();
              break;
            }
          }
        }
        return false;
      };

      cleanup();

      // Completely delete a path with 1 or 0 segments
      if (path.elem.pathSegList.numberOfItems <= 1) {
        pathActionsMethod.toSelectMode(path.elem);
        editorContext_ = pathActionsContext_.getEditorContext();
        editorContext_.canvas.deleteSelectedElements();
        return;
      }

      path.init();
      path.clearSelection();

      // TODO: Find right way to select point now
      // path.selectPt(selPt);
      if (window.opera) { // Opera repaints incorrectly
        path.elem.setAttribute('d',  path.elem.getAttribute('d'));
      }
      path.endChanges('Delete path node(s)');
    },
    // Can't seem to use `@borrows` here, so using `@see`
    /**
    * Smooth polyline into path.
    * @function module:path.pathActions.smoothPolylineIntoPath
    * @see module:path~smoothPolylineIntoPath
    */
    smoothPolylineIntoPath,
    /* eslint-enable  */
    /**
    * @param {?Integer} v See {@link https://www.w3.org/TR/SVG/single-page.html#paths-InterfaceSVGPathSeg}
    * @returns {void}
    */
    setSegType (v) {
      path?.setSegType(v);
    },
    /**
    * @param {string} attr
    * @param {Float} newValue
    * @returns {void}
    */
    moveNode (attr, newValue) {
      const selPts = path.selected_pts;
      if (!selPts.length) { return; }

      path.storeD();

      // Get first selected point
      const seg = path.segs[selPts[0]];
      const diff = { x: 0, y: 0 };
      diff[attr] = newValue - seg.item[attr];

      seg.move(diff.x, diff.y);
      path.endChanges('Move path point');
    },
    /**
    * @param {Element} elem
    * @returns {void}
    */
    fixEnd (elem) {
      // Adds an extra segment if the last seg before a Z doesn't end
      // at its M point
      // M0,0 L0,100 L100,100 z
      const segList = elem.pathSegList;
      const len = segList.numberOfItems;
      let lastM;
      for (let i = 0; i < len; ++i) {
        const item = segList.getItem(i);
        if (item.pathSegType === 2) { // 2 => M segment type (move to)
          lastM = item;
        }

        if (item.pathSegType === 1) { // 1 => Z segment type (close path)
          const prev = segList.getItem(i - 1);
          if (prev.x !== lastM.x || prev.y !== lastM.y) {
            // Add an L segment here
            const newseg = elem.createSVGPathSegLinetoAbs(lastM.x, lastM.y);
            segList.insertItemBefore(newseg, i);
            // Can this be done better?
            pathActionsMethod.fixEnd(elem);
            break;
          }
        }
      }
    },
    // Can't seem to use `@borrows` here, so using `@see`
    /**
    * Convert a path to one with only absolute or relative values.
    * @function module:path.pathActions.convertPath
    * @see module:path.convertPath
    */
    convertPath
  });
})();
// end pathActions