/* globals jQuery */ /** * Path functionality. * @module path * @license MIT * * @copyright 2011 Alexis Deveria, 2011 Jeff Schiller */ import './svgpathseg.js'; import {NS} from './namespaces.js'; import {getTransformList} from './svgtransformlist.js'; import {shortFloat} from './units.js'; import {ChangeElementCommand, BatchCommand} from './history.js'; import { transformPoint, getMatrix, snapToAngle, rectsIntersect, transformListToTransform } from './math.js'; import { assignAttributes, getElem, getRotationAngle, getBBox, getRefElem, findDefs, snapToGrid, isNullish, getBBox as utilsGetBBox } from './utilities.js'; import { supportsPathInsertItemBefore, supportsPathReplaceItem, isWebkit } from './browser.js'; const $ = jQuery; const segData = { 2: ['x', 'y'], // PATHSEG_MOVETO_ABS 4: ['x', 'y'], // PATHSEG_LINETO_ABS 6: ['x', 'y', 'x1', 'y1', 'x2', 'y2'], // PATHSEG_CURVETO_CUBIC_ABS 8: ['x', 'y', 'x1', 'y1'], // PATHSEG_CURVETO_QUADRATIC_ABS 10: ['x', 'y', 'r1', 'r2', 'angle', 'largeArcFlag', 'sweepFlag'], // PATHSEG_ARC_ABS 12: ['x'], // PATHSEG_LINETO_HORIZONTAL_ABS 14: ['y'], // PATHSEG_LINETO_VERTICAL_ABS 16: ['x', 'y', 'x2', 'y2'], // PATHSEG_CURVETO_CUBIC_SMOOTH_ABS 18: ['x', 'y'] // PATHSEG_CURVETO_QUADRATIC_SMOOTH_ABS }; /** * @tutorial LocaleDocs * @typedef {module:locale.LocaleStrings|PlainObject} module:path.uiStrings * @property {PlainObject} ui */ const uiStrings = {}; /** * @function module:path.setUiStrings * @param {module:path.uiStrings} strs * @returns {void} */ export const setUiStrings = function (strs) { Object.assign(uiStrings, strs.ui); }; let pathFuncs = []; let linkControlPts = true; // Stores references to paths via IDs. // TODO: Make this cross-document happy. let pathData = {}; /** * @function module:path.setLinkControlPoints * @param {boolean} lcp * @returns {void} */ export const setLinkControlPoints = function (lcp) { linkControlPts = lcp; }; /** * @name module:path.path * @type {null|module:path.Path} * @memberof module:path */ export let path = null; // eslint-disable-line import/no-mutable-exports let editorContext_ = null; /** * @external MouseEvent */ /** * Object with the following keys/values. * @typedef {PlainObject} module:path.SVGElementJSON * @property {string} element - Tag name of the SVG element to create * @property {PlainObject} attr - Has key-value attributes to assign to the new element. An `id` should be set so that {@link module:utilities.EditorContext#addSVGElementFromJson} can later re-identify the element for modification or replacement. * @property {boolean} [curStyles=false] - Indicates whether current style attributes should be applied first * @property {module:path.SVGElementJSON[]} [children] - Data objects to be added recursively as children * @property {string} [namespace="http://www.w3.org/2000/svg"] - Indicate a (non-SVG) namespace */ /** * @interface module:path.EditorContext * @property {module:select.SelectorManager} selectorManager * @property {module:svgcanvas.SvgCanvas} canvas */ /** * @function module:path.EditorContext#call * @param {"selected"|"changed"} ev - String with the event name * @param {module:svgcanvas.SvgCanvas#event:selected|module:svgcanvas.SvgCanvas#event:changed} arg - Argument to pass through to the callback function. If the event is "changed", an array of `Element`s is passed; if "selected", a single-item array of `Element` is passed. * @returns {void} */ /** * @function module:path.EditorContext#resetD * @param {SVGPathElement} p * @returns {void} */ /** * Note: This doesn't round to an integer necessarily. * @function module:path.EditorContext#round * @param {Float} val * @returns {Float} Rounded value to nearest value based on `currentZoom` */ /** * @function module:path.EditorContext#clearSelection * @param {boolean} [noCall] - When `true`, does not call the "selected" handler * @returns {void} */ /** * @function module:path.EditorContext#addToSelection * @param {Element[]} elemsToAdd - An array of DOM elements to add to the selection * @param {boolean} showGrips - Indicates whether the resize grips should be shown * @returns {void} */ /** * @function module:path.EditorContext#addCommandToHistory * @param {Command} cmd * @returns {void} */ /** * @function module:path.EditorContext#remapElement * @param {Element} selected - DOM element to be changed * @param {PlainObject} changes - Object with changes to be remapped * @param {SVGMatrix} m - Matrix object to use for remapping coordinates * @returns {void} */ /** * @function module:path.EditorContext#addSVGElementFromJson * @param {module:path.SVGElementJSON} data * @returns {Element} The new element */ /** * @function module:path.EditorContext#getGridSnapping * @returns {boolean} */ /** * @function module:path.EditorContext#getOpacity * @returns {Float} */ /** * @function module:path.EditorContext#getSelectedElements * @returns {Element[]} the array with selected DOM elements */ /** * @function module:path.EditorContext#getContainer * @returns {Element} */ /** * @function module:path.EditorContext#setStarted * @param {boolean} s * @returns {void} */ /** * @function module:path.EditorContext#getRubberBox * @returns {SVGRectElement} */ /** * @function module:path.EditorContext#setRubberBox * @param {SVGRectElement} rb * @returns {SVGRectElement} Same as parameter passed in */ /** * @function module:path.EditorContext#addPtsToSelection * @param {PlainObject} cfg * @param {boolean} cfg.closedSubpath * @param {SVGCircleElement[]} cfg.grips * @returns {void} */ /** * @function module:path.EditorContext#endChanges * @param {PlainObject} cfg * @param {string} cfg.cmd * @param {Element} cfg.elem * @returns {void} */ /** * @function module:path.EditorContext#getCurrentZoom * @returns {Float} The current zoom level */ /** * Returns the last created DOM element ID string. * @function module:path.EditorContext#getId * @returns {string} */ /** * Creates and returns a unique ID string for a DOM element. * @function module:path.EditorContext#getNextId * @returns {string} */ /** * Gets the desired element from a mouse event. * @function module:path.EditorContext#getMouseTarget * @param {external:MouseEvent} evt - Event object from the mouse event * @returns {Element} DOM element we want */ /** * @function module:path.EditorContext#getCurrentMode * @returns {string} */ /** * @function module:path.EditorContext#setCurrentMode * @param {string} cm The mode * @returns {string} The same mode as passed in */ /** * @function module:path.EditorContext#getDrawnPath * @returns {SVGPathElement|null} */ /** * @function module:path.EditorContext#setDrawnPath * @param {SVGPathElement|null} dp * @returns {SVGPathElement|null} The same value as passed in */ /** * @function module:path.EditorContext#getSVGRoot * @returns {SVGSVGElement} */ /** * @function module:path.init * @param {module:path.EditorContext} editorContext * @returns {void} */ export const init = function (editorContext) { editorContext_ = editorContext; pathFuncs = [0, 'ClosePath']; const pathFuncsStrs = [ 'Moveto', 'Lineto', 'CurvetoCubic', 'CurvetoQuadratic', 'Arc', 'LinetoHorizontal', 'LinetoVertical', 'CurvetoCubicSmooth', 'CurvetoQuadraticSmooth' ]; $.each(pathFuncsStrs, function (i, s) { pathFuncs.push(s + 'Abs'); pathFuncs.push(s + 'Rel'); }); }; /** * @function module:path.insertItemBefore * @param {Element} elem * @param {Segment} newseg * @param {Integer} index * @returns {void} */ export const insertItemBefore = function (elem, newseg, index) { // Support insertItemBefore on paths for FF2 const list = elem.pathSegList; if (supportsPathInsertItemBefore()) { list.insertItemBefore(newseg, index); return; } const len = list.numberOfItems; const arr = []; for (let i = 0; i < len; i++) { const curSeg = list.getItem(i); arr.push(curSeg); } list.clear(); for (let i = 0; i < len; i++) { if (i === index) { // index + 1 list.appendItem(newseg); } list.appendItem(arr[i]); } }; /** * @function module:path.ptObjToArr * @todo See if this should just live in `replacePathSeg` * @param {string} type * @param {SVGPathSegMovetoAbs|SVGPathSegLinetoAbs|SVGPathSegCurvetoCubicAbs|SVGPathSegCurvetoQuadraticAbs|SVGPathSegArcAbs|SVGPathSegLinetoHorizontalAbs|SVGPathSegLinetoVerticalAbs|SVGPathSegCurvetoCubicSmoothAbs|SVGPathSegCurvetoQuadraticSmoothAbs} segItem * @returns {ArgumentsArray} */ export const ptObjToArr = function (type, segItem) { const props = segData[type]; return props.map((prop) => { return segItem[prop]; }); }; /** * @function module:path.getGripPt * @param {Segment} seg * @param {module:math.XYObject} altPt * @returns {module:math.XYObject} */ export const getGripPt = function (seg, altPt) { const {path: pth} = seg; let out = { x: altPt ? altPt.x : seg.item.x, y: altPt ? altPt.y : seg.item.y }; if (pth.matrix) { const pt = transformPoint(out.x, out.y, pth.matrix); out = pt; } const currentZoom = editorContext_.getCurrentZoom(); out.x *= currentZoom; out.y *= currentZoom; return out; }; /** * @function module:path.getPointFromGrip * @param {module:math.XYObject} pt * @param {module:path.Path} pth * @returns {module:math.XYObject} */ export const getPointFromGrip = function (pt, pth) { const out = { x: pt.x, y: pt.y }; if (pth.matrix) { pt = transformPoint(out.x, out.y, pth.imatrix); out.x = pt.x; out.y = pt.y; } const currentZoom = editorContext_.getCurrentZoom(); out.x /= currentZoom; out.y /= currentZoom; return out; }; /** * Requires prior call to `setUiStrings` if `xlink:title` * to be set on the grip. * @function module:path.addPointGrip * @param {Integer} index * @param {Integer} x * @param {Integer} y * @returns {SVGCircleElement} */ export const addPointGrip = function (index, x, y) { // create the container of all the point grips const pointGripContainer = getGripContainer(); let pointGrip = getElem('pathpointgrip_' + index); // create it if (!pointGrip) { pointGrip = document.createElementNS(NS.SVG, 'circle'); const atts = { id: 'pathpointgrip_' + index, display: 'none', r: 4, fill: '#0FF', stroke: '#00F', 'stroke-width': 2, cursor: 'move', style: 'pointer-events:all' }; if ('pathNodeTooltip' in uiStrings) { // May be empty if running path.js without svg-editor atts['xlink:title'] = uiStrings.pathNodeTooltip; } assignAttributes(pointGrip, atts); pointGrip = pointGripContainer.appendChild(pointGrip); const grip = $('#pathpointgrip_' + index); grip.dblclick(function () { if (path) { path.setSegType(); } }); } if (x && y) { // set up the point grip element and display it assignAttributes(pointGrip, { cx: x, cy: y, display: 'inline' }); } return pointGrip; }; /** * @function module:path.getGripContainer * @returns {Element} */ export const getGripContainer = function () { let c = getElem('pathpointgrip_container'); if (!c) { const parentElement = getElem('selectorParentGroup'); c = parentElement.appendChild(document.createElementNS(NS.SVG, 'g')); c.id = 'pathpointgrip_container'; } return c; }; /** * Requires prior call to `setUiStrings` if `xlink:title` * to be set on the grip. * @function module:path.addCtrlGrip * @param {string} id * @returns {SVGCircleElement} */ export const addCtrlGrip = function (id) { let pointGrip = getElem('ctrlpointgrip_' + id); if (pointGrip) { return pointGrip; } pointGrip = document.createElementNS(NS.SVG, 'circle'); const atts = { id: 'ctrlpointgrip_' + id, display: 'none', r: 4, fill: '#0FF', stroke: '#55F', 'stroke-width': 1, cursor: 'move', style: 'pointer-events:all' }; if ('pathCtrlPtTooltip' in uiStrings) { // May be empty if running path.js without svg-editor atts['xlink:title'] = uiStrings.pathCtrlPtTooltip; } assignAttributes(pointGrip, atts); getGripContainer().append(pointGrip); return pointGrip; }; /** * @function module:path.getCtrlLine * @param {string} id * @returns {SVGLineElement} */ export const getCtrlLine = function (id) { let ctrlLine = getElem('ctrlLine_' + id); if (ctrlLine) { return ctrlLine; } ctrlLine = document.createElementNS(NS.SVG, 'line'); assignAttributes(ctrlLine, { id: 'ctrlLine_' + id, stroke: '#555', 'stroke-width': 1, style: 'pointer-events:none' }); getGripContainer().append(ctrlLine); return ctrlLine; }; /** * @function module:path.getPointGrip * @param {Segment} seg * @param {boolean} update * @returns {SVGCircleElement} */ export const getPointGrip = function (seg, update) { const {index} = seg; const pointGrip = addPointGrip(index); if (update) { const pt = getGripPt(seg); assignAttributes(pointGrip, { cx: pt.x, cy: pt.y, display: 'inline' }); } return pointGrip; }; /** * @function module:path.getControlPoints * @param {Segment} seg * @returns {PlainObject} */ export const getControlPoints = function (seg) { const {item, index} = seg; if (!('x1' in item) || !('x2' in item)) { return null; } const cpt = {}; /* const pointGripContainer = */ getGripContainer(); // Note that this is intentionally not seg.prev.item const prev = path.segs[index - 1].item; const segItems = [prev, item]; for (let i = 1; i < 3; i++) { const id = index + 'c' + i; const ctrlLine = cpt['c' + i + '_line'] = getCtrlLine(id); const pt = getGripPt(seg, {x: item['x' + i], y: item['y' + i]}); const gpt = getGripPt(seg, {x: segItems[i - 1].x, y: segItems[i - 1].y}); assignAttributes(ctrlLine, { x1: pt.x, y1: pt.y, x2: gpt.x, y2: gpt.y, display: 'inline' }); cpt['c' + i + '_line'] = ctrlLine; // create it const pointGrip = cpt['c' + i] = addCtrlGrip(id); assignAttributes(pointGrip, { cx: pt.x, cy: pt.y, display: 'inline' }); cpt['c' + i] = pointGrip; } return cpt; }; /** * This replaces the segment at the given index. Type is given as number. * @function module:path.replacePathSeg * @param {Integer} type Possible values set during {@link module:path.init} * @param {Integer} index * @param {ArgumentsArray} pts * @param {SVGPathElement} elem * @returns {void} */ export const replacePathSeg = function (type, index, pts, elem) { const pth = elem || path.elem; const func = 'createSVGPathSeg' + pathFuncs[type]; const seg = pth[func](...pts); if (supportsPathReplaceItem()) { pth.pathSegList.replaceItem(seg, index); } else { const segList = pth.pathSegList; const len = segList.numberOfItems; const arr = []; for (let i = 0; i < len; i++) { const curSeg = segList.getItem(i); arr.push(curSeg); } segList.clear(); for (let i = 0; i < len; i++) { if (i === index) { segList.appendItem(seg); } else { segList.appendItem(arr[i]); } } } }; /** * @function module:path.getSegSelector * @param {Segment} seg * @param {boolean} update * @returns {SVGPathElement} */ export const getSegSelector = function (seg, update) { const {index} = seg; let segLine = getElem('segline_' + index); if (!segLine) { const pointGripContainer = getGripContainer(); // create segline segLine = document.createElementNS(NS.SVG, 'path'); assignAttributes(segLine, { id: 'segline_' + index, display: 'none', fill: 'none', stroke: '#0FF', 'stroke-width': 2, style: 'pointer-events:none', d: 'M0,0 0,0' }); pointGripContainer.append(segLine); } if (update) { const {prev} = seg; if (!prev) { segLine.setAttribute('display', 'none'); return segLine; } const pt = getGripPt(prev); // Set start point replacePathSeg(2, 0, [pt.x, pt.y], segLine); const pts = ptObjToArr(seg.type, seg.item); // , true); for (let i = 0; i < pts.length; i += 2) { const point = getGripPt(seg, {x: pts[i], y: pts[i + 1]}); pts[i] = point.x; pts[i + 1] = point.y; } replacePathSeg(seg.type, 1, pts, segLine); } return segLine; }; /** * @typedef {PlainObject} Point * @property {Integer} x The x value * @property {Integer} y The y value */ /** * Takes three points and creates a smoother line based on them. * @function module:path.smoothControlPoints * @param {Point} ct1 - Object with x and y values (first control point) * @param {Point} ct2 - Object with x and y values (second control point) * @param {Point} pt - Object with x and y values (third point) * @returns {Point[]} Array of two "smoothed" point objects */ export const smoothControlPoints = function (ct1, ct2, pt) { // each point must not be the origin const x1 = ct1.x - pt.x, y1 = ct1.y - pt.y, x2 = ct2.x - pt.x, y2 = ct2.y - pt.y; if ((x1 !== 0 || y1 !== 0) && (x2 !== 0 || y2 !== 0)) { const r1 = Math.sqrt(x1 * x1 + y1 * y1), r2 = Math.sqrt(x2 * x2 + y2 * y2), nct1 = editorContext_.getSVGRoot().createSVGPoint(), nct2 = editorContext_.getSVGRoot().createSVGPoint(); let anglea = Math.atan2(y1, x1), angleb = Math.atan2(y2, x2); if (anglea < 0) { anglea += 2 * Math.PI; } if (angleb < 0) { angleb += 2 * Math.PI; } const angleBetween = Math.abs(anglea - angleb), angleDiff = Math.abs(Math.PI - angleBetween) / 2; let newAnglea, newAngleb; if (anglea - angleb > 0) { newAnglea = angleBetween < Math.PI ? (anglea + angleDiff) : (anglea - angleDiff); newAngleb = angleBetween < Math.PI ? (angleb - angleDiff) : (angleb + angleDiff); } else { newAnglea = angleBetween < Math.PI ? (anglea - angleDiff) : (anglea + angleDiff); newAngleb = angleBetween < Math.PI ? (angleb + angleDiff) : (angleb - angleDiff); } // rotate the points nct1.x = r1 * Math.cos(newAnglea) + pt.x; nct1.y = r1 * Math.sin(newAnglea) + pt.y; nct2.x = r2 * Math.cos(newAngleb) + pt.x; nct2.y = r2 * Math.sin(newAngleb) + pt.y; return [nct1, nct2]; } return undefined; }; /** * */ export class Segment { /** * @param {Integer} index * @param {SVGPathSeg} item * @todo Is `item` be more constrained here? */ constructor (index, item) { this.selected = false; this.index = index; this.item = item; this.type = item.pathSegType; this.ctrlpts = []; this.ptgrip = null; this.segsel = null; } /** * @param {boolean} y * @returns {void} */ showCtrlPts (y) { for (const i in this.ctrlpts) { if ({}.hasOwnProperty.call(this.ctrlpts, i)) { this.ctrlpts[i].setAttribute('display', y ? 'inline' : 'none'); } } } /** * @param {boolean} y * @returns {void} */ selectCtrls (y) { $('#ctrlpointgrip_' + this.index + 'c1, #ctrlpointgrip_' + this.index + 'c2') .attr('fill', y ? '#0FF' : '#EEE'); } /** * @param {boolean} y * @returns {void} */ show (y) { if (this.ptgrip) { this.ptgrip.setAttribute('display', y ? 'inline' : 'none'); this.segsel.setAttribute('display', y ? 'inline' : 'none'); // Show/hide all control points if available this.showCtrlPts(y); } } /** * @param {boolean} y * @returns {void} */ select (y) { if (this.ptgrip) { this.ptgrip.setAttribute('stroke', y ? '#0FF' : '#00F'); this.segsel.setAttribute('display', y ? 'inline' : 'none'); if (this.ctrlpts) { this.selectCtrls(y); } this.selected = y; } } /** * @returns {void} */ addGrip () { this.ptgrip = getPointGrip(this, true); this.ctrlpts = getControlPoints(this); // , true); this.segsel = getSegSelector(this, true); } /** * @param {boolean} full * @returns {void} */ update (full) { if (this.ptgrip) { const pt = getGripPt(this); assignAttributes(this.ptgrip, { cx: pt.x, cy: pt.y }); getSegSelector(this, true); if (this.ctrlpts) { if (full) { this.item = path.elem.pathSegList.getItem(this.index); this.type = this.item.pathSegType; } getControlPoints(this); } // this.segsel.setAttribute('display', y ? 'inline' : 'none'); } } /** * @param {Integer} dx * @param {Integer} dy * @returns {void} */ move (dx, dy) { const {item} = this; const curPts = this.ctrlpts ? [ item.x += dx, item.y += dy, item.x1, item.y1, item.x2 += dx, item.y2 += dy ] : [item.x += dx, item.y += dy]; replacePathSeg( this.type, this.index, // type 10 means ARC this.type === 10 ? ptObjToArr(this.type, item) : curPts ); if (this.next && this.next.ctrlpts) { const next = this.next.item; const nextPts = [ next.x, next.y, next.x1 += dx, next.y1 += dy, next.x2, next.y2 ]; replacePathSeg(this.next.type, this.next.index, nextPts); } if (this.mate) { // The last point of a closed subpath has a 'mate', // which is the 'M' segment of the subpath const {item: itm} = this.mate; const pts = [itm.x += dx, itm.y += dy]; replacePathSeg(this.mate.type, this.mate.index, pts); // Has no grip, so does not need 'updating'? } this.update(true); if (this.next) { this.next.update(true); } } /** * @param {Integer} num * @returns {void} */ setLinked (num) { let seg, anum, pt; if (num === 2) { anum = 1; seg = this.next; if (!seg) { return; } pt = this.item; } else { anum = 2; seg = this.prev; if (!seg) { return; } pt = seg.item; } const {item} = seg; item['x' + anum] = pt.x + (pt.x - this.item['x' + num]); item['y' + anum] = pt.y + (pt.y - this.item['y' + num]); const pts = [ item.x, item.y, item.x1, item.y1, item.x2, item.y2 ]; replacePathSeg(seg.type, seg.index, pts); seg.update(true); } /** * @param {Integer} num * @param {Integer} dx * @param {Integer} dy * @returns {void} */ moveCtrl (num, dx, dy) { const {item} = this; item['x' + num] += dx; item['y' + num] += dy; const pts = [ item.x, item.y, item.x1, item.y1, item.x2, item.y2 ]; replacePathSeg(this.type, this.index, pts); this.update(true); } /** * @param {Integer} newType Possible values set during {@link module:path.init} * @param {ArgumentsArray} pts * @returns {void} */ setType (newType, pts) { replacePathSeg(newType, this.index, pts); this.type = newType; this.item = path.elem.pathSegList.getItem(this.index); this.showCtrlPts(newType === 6); this.ctrlpts = getControlPoints(this); this.update(true); } } /** * */ export class Path { /** * @param {SVGPathElement} elem * @throws {Error} If constructed without a path element */ constructor (elem) { if (!elem || elem.tagName !== 'path') { throw new Error('svgedit.path.Path constructed without a element'); } this.elem = elem; this.segs = []; this.selected_pts = []; path = this; // eslint-disable-line consistent-this this.init(); } /** * Reset path data. * @returns {module:path.Path} */ init () { // Hide all grips, etc // fixed, needed to work on all found elements, not just first $(getGripContainer()).find('*').each(function () { $(this).attr('display', 'none'); }); const segList = this.elem.pathSegList; const len = segList.numberOfItems; this.segs = []; this.selected_pts = []; this.first_seg = null; // Set up segs array for (let i = 0; i < len; i++) { const item = segList.getItem(i); const segment = new Segment(i, item); segment.path = this; this.segs.push(segment); } const {segs} = this; let startI = null; for (let i = 0; i < len; i++) { const seg = segs[i]; const nextSeg = (i + 1) >= len ? null : segs[i + 1]; const prevSeg = (i - 1) < 0 ? null : segs[i - 1]; if (seg.type === 2) { if (prevSeg && prevSeg.type !== 1) { // New sub-path, last one is open, // so add a grip to last sub-path's first point const startSeg = segs[startI]; startSeg.next = segs[startI + 1]; startSeg.next.prev = startSeg; startSeg.addGrip(); } // Remember that this is a starter seg startI = i; } else if (nextSeg && nextSeg.type === 1) { // This is the last real segment of a closed sub-path // Next is first seg after "M" seg.next = segs[startI + 1]; // First seg after "M"'s prev is this seg.next.prev = seg; seg.mate = segs[startI]; seg.addGrip(); if (isNullish(this.first_seg)) { this.first_seg = seg; } } else if (!nextSeg) { if (seg.type !== 1) { // Last seg, doesn't close so add a grip // to last sub-path's first point const startSeg = segs[startI]; startSeg.next = segs[startI + 1]; startSeg.next.prev = startSeg; startSeg.addGrip(); seg.addGrip(); if (!this.first_seg) { // Open path, so set first as real first and add grip this.first_seg = segs[startI]; } } } else if (seg.type !== 1) { // Regular segment, so add grip and its "next" seg.addGrip(); // Don't set its "next" if it's an "M" if (nextSeg && nextSeg.type !== 2) { seg.next = nextSeg; seg.next.prev = seg; } } } return this; } /** * @callback module:path.PathEachSegCallback * @this module:path.Segment * @param {Integer} i The index of the seg being iterated * @returns {boolean|void} Will stop execution of `eachSeg` if returns `false` */ /** * @param {module:path.PathEachSegCallback} fn * @returns {void} */ eachSeg (fn) { const len = this.segs.length; for (let i = 0; i < len; i++) { const ret = fn.call(this.segs[i], i); if (ret === false) { break; } } } /** * @param {Integer} index * @returns {void} */ addSeg (index) { // Adds a new segment const seg = this.segs[index]; if (!seg.prev) { return; } const {prev} = seg; let newseg, newX, newY; switch (seg.item.pathSegType) { case 4: { newX = (seg.item.x + prev.item.x) / 2; newY = (seg.item.y + prev.item.y) / 2; newseg = this.elem.createSVGPathSegLinetoAbs(newX, newY); break; } case 6: { // make it a curved segment to preserve the shape (WRS) // https://en.wikipedia.org/wiki/De_Casteljau%27s_algorithm#Geometric_interpretation const p0x = (prev.item.x + seg.item.x1) / 2; const p1x = (seg.item.x1 + seg.item.x2) / 2; const p2x = (seg.item.x2 + seg.item.x) / 2; const p01x = (p0x + p1x) / 2; const p12x = (p1x + p2x) / 2; newX = (p01x + p12x) / 2; const p0y = (prev.item.y + seg.item.y1) / 2; const p1y = (seg.item.y1 + seg.item.y2) / 2; const p2y = (seg.item.y2 + seg.item.y) / 2; const p01y = (p0y + p1y) / 2; const p12y = (p1y + p2y) / 2; newY = (p01y + p12y) / 2; newseg = this.elem.createSVGPathSegCurvetoCubicAbs(newX, newY, p0x, p0y, p01x, p01y); const pts = [seg.item.x, seg.item.y, p12x, p12y, p2x, p2y]; replacePathSeg(seg.type, index, pts); break; } } insertItemBefore(this.elem, newseg, index); } /** * @param {Integer} index * @returns {void} */ deleteSeg (index) { const seg = this.segs[index]; const list = this.elem.pathSegList; seg.show(false); const {next} = seg; if (seg.mate) { // Make the next point be the "M" point const pt = [next.item.x, next.item.y]; replacePathSeg(2, next.index, pt); // Reposition last node replacePathSeg(4, seg.index, pt); list.removeItem(seg.mate.index); } else if (!seg.prev) { // First node of open path, make next point the M // const {item} = seg; const pt = [next.item.x, next.item.y]; replacePathSeg(2, seg.next.index, pt); list.removeItem(index); } else { list.removeItem(index); } } /** * @param {Integer} index * @returns {void} */ removePtFromSelection (index) { const pos = this.selected_pts.indexOf(index); if (pos === -1) { return; } this.segs[index].select(false); this.selected_pts.splice(pos, 1); } /** * @returns {void} */ clearSelection () { this.eachSeg(function () { // 'this' is the segment here this.select(false); }); this.selected_pts = []; } /** * @returns {void} */ storeD () { this.last_d = this.elem.getAttribute('d'); } /** * @param {Integer} y * @returns {Path} */ show (y) { // Shows this path's segment grips this.eachSeg(function () { // 'this' is the segment here this.show(y); }); if (y) { this.selectPt(this.first_seg.index); } return this; } /** * Move selected points. * @param {Integer} dx * @param {Integer} dy * @returns {void} */ movePts (dx, dy) { let i = this.selected_pts.length; while (i--) { const seg = this.segs[this.selected_pts[i]]; seg.move(dx, dy); } } /** * @param {Integer} dx * @param {Integer} dy * @returns {void} */ moveCtrl (dx, dy) { const seg = this.segs[this.selected_pts[0]]; seg.moveCtrl(this.dragctrl, dx, dy); if (linkControlPts) { seg.setLinked(this.dragctrl); } } /** * @param {?Integer} newType See {@link https://www.w3.org/TR/SVG/single-page.html#paths-InterfaceSVGPathSeg} * @returns {void} */ setSegType (newType) { this.storeD(); let i = this.selected_pts.length; let text; while (i--) { const selPt = this.selected_pts[i]; // Selected seg const cur = this.segs[selPt]; const {prev} = cur; if (!prev) { continue; } if (!newType) { // double-click, so just toggle text = 'Toggle Path Segment Type'; // Toggle segment to curve/straight line const oldType = cur.type; newType = (oldType === 6) ? 4 : 6; } newType = Number(newType); const curX = cur.item.x; const curY = cur.item.y; const prevX = prev.item.x; const prevY = prev.item.y; let points; switch (newType) { case 6: { if (cur.olditem) { const old = cur.olditem; points = [curX, curY, old.x1, old.y1, old.x2, old.y2]; } else { const diffX = curX - prevX; const diffY = curY - prevY; // get control points from straight line segment /* const ct1x = (prevX + (diffY/2)); const ct1y = (prevY - (diffX/2)); const ct2x = (curX + (diffY/2)); const ct2y = (curY - (diffX/2)); */ // create control points on the line to preserve the shape (WRS) const ct1x = (prevX + (diffX / 3)); const ct1y = (prevY + (diffY / 3)); const ct2x = (curX - (diffX / 3)); const ct2y = (curY - (diffY / 3)); points = [curX, curY, ct1x, ct1y, ct2x, ct2y]; } break; } case 4: { points = [curX, curY]; // Store original prevve segment nums cur.olditem = cur.item; break; } } cur.setType(newType, points); } path.endChanges(text); } /** * @param {Integer} pt * @param {Integer} ctrlNum * @returns {void} */ selectPt (pt, ctrlNum) { this.clearSelection(); if (isNullish(pt)) { this.eachSeg(function (i) { // 'this' is the segment here. if (this.prev) { pt = i; } }); } this.addPtsToSelection(pt); if (ctrlNum) { this.dragctrl = ctrlNum; if (linkControlPts) { this.segs[pt].setLinked(ctrlNum); } } } /** * Update position of all points. * @returns {Path} */ update () { const {elem} = this; if (getRotationAngle(elem)) { this.matrix = getMatrix(elem); this.imatrix = this.matrix.inverse(); } else { this.matrix = null; this.imatrix = null; } this.eachSeg(function (i) { this.item = elem.pathSegList.getItem(i); this.update(); }); return this; } /** * @param {string} text * @returns {void} */ endChanges (text) { if (isWebkit()) { editorContext_.resetD(this.elem); } const cmd = new ChangeElementCommand(this.elem, {d: this.last_d}, text); editorContext_.endChanges({cmd, elem: this.elem}); } /** * @param {Integer|Integer[]} indexes * @returns {void} */ addPtsToSelection (indexes) { if (!Array.isArray(indexes)) { indexes = [indexes]; } indexes.forEach((index) => { const seg = this.segs[index]; if (seg.ptgrip) { if (!this.selected_pts.includes(index) && index >= 0) { this.selected_pts.push(index); } } }); this.selected_pts.sort(); let i = this.selected_pts.length; const grips = []; grips.length = i; // Loop through points to be selected and highlight each while (i--) { const pt = this.selected_pts[i]; const seg = this.segs[pt]; seg.select(true); grips[i] = seg.ptgrip; } const closedSubpath = Path.subpathIsClosed(this.selected_pts[0]); editorContext_.addPtsToSelection({grips, closedSubpath}); } // STATIC /** * @param {Integer} index * @returns {boolean} */ static subpathIsClosed (index) { let clsd = false; // Check if subpath is already open path.eachSeg(function (i) { if (i <= index) { return true; } if (this.type === 2) { // Found M first, so open return false; } if (this.type === 1) { // Found Z first, so closed clsd = true; return false; } return true; }); return clsd; } } /** * @function module:path.getPath_ * @param {SVGPathElement} elem * @returns {module:path.Path} */ export const getPath_ = function (elem) { let p = pathData[elem.id]; if (!p) { p = pathData[elem.id] = new Path(elem); } return p; }; /** * @function module:path.removePath_ * @param {string} id * @returns {void} */ export const removePath_ = function (id) { if (id in pathData) { delete pathData[id]; } }; let newcx, newcy, oldcx, oldcy, angle; const getRotVals = function (x, y) { let dx = x - oldcx; let dy = y - oldcy; // rotate the point around the old center let r = Math.sqrt(dx * dx + dy * dy); let theta = Math.atan2(dy, dx) + angle; dx = r * Math.cos(theta) + oldcx; dy = r * Math.sin(theta) + oldcy; // dx,dy should now hold the actual coordinates of each // point after being rotated // now we want to rotate them around the new center in the reverse direction dx -= newcx; dy -= newcy; r = Math.sqrt(dx * dx + dy * dy); theta = Math.atan2(dy, dx) - angle; return {x: r * Math.cos(theta) + newcx, y: r * Math.sin(theta) + newcy}; }; // If the path was rotated, we must now pay the piper: // Every path point must be rotated into the rotated coordinate system of // its old center, then determine the new center, then rotate it back // This is because we want the path to remember its rotation /** * @function module:path.recalcRotatedPath * @todo This is still using ye olde transform methods, can probably * be optimized or even taken care of by `recalculateDimensions` * @returns {void} */ export const recalcRotatedPath = function () { const currentPath = path.elem; angle = getRotationAngle(currentPath, true); if (!angle) { return; } // selectedBBoxes[0] = path.oldbbox; const oldbox = path.oldbbox; // selectedBBoxes[0], oldcx = oldbox.x + oldbox.width / 2; oldcy = oldbox.y + oldbox.height / 2; const box = getBBox(currentPath); newcx = box.x + box.width / 2; newcy = box.y + box.height / 2; // un-rotate the new center to the proper position const dx = newcx - oldcx, dy = newcy - oldcy, r = Math.sqrt(dx * dx + dy * dy), theta = Math.atan2(dy, dx) + angle; newcx = r * Math.cos(theta) + oldcx; newcy = r * Math.sin(theta) + oldcy; const list = currentPath.pathSegList; let i = list.numberOfItems; while (i) { i -= 1; const seg = list.getItem(i), type = seg.pathSegType; if (type === 1) { continue; } const rvals = getRotVals(seg.x, seg.y), points = [rvals.x, rvals.y]; if (!isNullish(seg.x1) && !isNullish(seg.x2)) { const cVals1 = getRotVals(seg.x1, seg.y1); const cVals2 = getRotVals(seg.x2, seg.y2); points.splice(points.length, 0, cVals1.x, cVals1.y, cVals2.x, cVals2.y); } replacePathSeg(type, i, points); } // loop for each point /* box = */ getBBox(currentPath); // selectedBBoxes[0].x = box.x; selectedBBoxes[0].y = box.y; // selectedBBoxes[0].width = box.width; selectedBBoxes[0].height = box.height; // now we must set the new transform to be rotated around the new center const Rnc = editorContext_.getSVGRoot().createSVGTransform(), tlist = getTransformList(currentPath); Rnc.setRotate((angle * 180.0 / Math.PI), newcx, newcy); tlist.replaceItem(Rnc, 0); }; // ==================================== // Public API starts here /** * @function module:path.clearData * @returns {void} */ export const clearData = function () { pathData = {}; }; // Making public for mocking /** * @function module:path.reorientGrads * @param {Element} elem * @param {SVGMatrix} m * @returns {void} */ export const reorientGrads = function (elem, m) { const bb = utilsGetBBox(elem); for (let i = 0; i < 2; i++) { const type = i === 0 ? 'fill' : 'stroke'; const attrVal = elem.getAttribute(type); if (attrVal && attrVal.startsWith('url(')) { const grad = getRefElem(attrVal); if (grad.tagName === 'linearGradient') { let x1 = grad.getAttribute('x1') || 0; let y1 = grad.getAttribute('y1') || 0; let x2 = grad.getAttribute('x2') || 1; let y2 = grad.getAttribute('y2') || 0; // Convert to USOU points x1 = (bb.width * x1) + bb.x; y1 = (bb.height * y1) + bb.y; x2 = (bb.width * x2) + bb.x; y2 = (bb.height * y2) + bb.y; // Transform those points const pt1 = transformPoint(x1, y1, m); const pt2 = transformPoint(x2, y2, m); // Convert back to BB points const gCoords = { x1: (pt1.x - bb.x) / bb.width, y1: (pt1.y - bb.y) / bb.height, x2: (pt2.x - bb.x) / bb.width, y2: (pt2.y - bb.y) / bb.height }; const newgrad = grad.cloneNode(true); $(newgrad).attr(gCoords); newgrad.id = editorContext_.getNextId(); findDefs().append(newgrad); elem.setAttribute(type, 'url(#' + newgrad.id + ')'); } } } }; /** * This is how we map paths to our preferred relative segment types. * @name module:path.pathMap * @type {GenericArray} */ const pathMap = [ 0, 'z', 'M', 'm', 'L', 'l', 'C', 'c', 'Q', 'q', 'A', 'a', 'H', 'h', 'V', 'v', 'S', 's', 'T', 't' ]; /** * 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, 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, y = seg.y || 0, x1 = seg.x1 || 0, y1 = seg.y1 || 0, x2 = seg.x2 || 0, y2 = seg.y2 || 0; const type = seg.pathSegType; let letter = pathMap[type]['to' + (toRel ? 'Lower' : 'Upper') + 'Case'](); switch (type) { case 1: // z,Z closepath (Z/z) d += 'z'; if (lastM && !toRel) { curx = lastM[0]; cury = lastM[1]; } break; case 12: // absolute horizontal line (H) x -= curx; // Fallthrough case 13: // relative horizontal line (h) if (toRel) { curx += x; letter = 'l'; } else { x += curx; curx = x; letter = 'L'; } // Convert to "line" for easier editing d += pathDSegment(letter, [[x, cury]]); break; case 14: // absolute vertical line (V) y -= cury; // Fallthrough case 15: // relative vertical line (v) if (toRel) { cury += y; letter = 'l'; } else { y += cury; cury = y; letter = 'L'; } // Convert to "line" for easier editing d += pathDSegment(letter, [[curx, y]]); break; case 2: // absolute move (M) case 4: // absolute line (L) case 18: // absolute smooth quad (T) x -= curx; y -= cury; // Fallthrough case 5: // relative line (l) case 3: // relative move (m) case 19: // relative smooth quad (t) if (toRel) { curx += x; cury += y; } else { x += curx; y += cury; curx = x; cury = y; } if (type === 2 || type === 3) { lastM = [curx, cury]; } d += pathDSegment(letter, [[x, y]]); break; case 6: // absolute cubic (C) x -= curx; x1 -= curx; x2 -= curx; y -= cury; y1 -= cury; y2 -= cury; // Fallthrough case 7: // relative cubic (c) if (toRel) { curx += x; cury += y; } else { x += curx; x1 += curx; x2 += curx; y += cury; y1 += cury; y2 += cury; curx = x; cury = y; } d += pathDSegment(letter, [[x1, y1], [x2, y2], [x, y]]); break; case 8: // absolute quad (Q) x -= curx; x1 -= curx; y -= cury; y1 -= cury; // Fallthrough case 9: // relative quad (q) if (toRel) { curx += x; cury += y; } else { x += curx; x1 += curx; y += cury; y1 += cury; curx = x; cury = y; } d += pathDSegment(letter, [[x1, y1], [x, y]]); break; // eslint-disable-next-line sonarjs/no-duplicated-branches case 10: // absolute elliptical arc (A) x -= curx; y -= cury; // Fallthrough case 11: // relative elliptical arc (a) if (toRel) { curx += x; cury += y; } else { x += curx; y += cury; curx = x; cury = y; } d += pathDSegment(letter, [[seg.r1, seg.r2]], [ seg.angle, (seg.largeArcFlag ? 1 : 0), (seg.sweepFlag ? 1 : 0) ], [x, y]); break; case 16: // absolute smooth cubic (S) x -= curx; x2 -= curx; y -= cury; y2 -= cury; // Fallthrough case 17: // relative smooth cubic (s) if (toRel) { curx += x; cury += y; } else { x += curx; x2 += curx; y += cury; y2 += cury; curx = x; cury = y; } 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>} points - x,y points * @param {GenericArray>} [morePoints] - x,y points * @param {Integer[]} [lastPoint] - x,y point * @returns {string} */ function pathDSegment (letter, points, morePoints, lastPoint) { $.each(points, function (i, pnt) { points[i] = shortFloat(pnt); }); let segment = letter + points.join(' '); if (morePoints) { segment += ' ' + morePoints.join(' '); } if (lastPoint) { segment += ' ' + shortFloat(lastPoint); } return segment; } /* eslint-disable jsdoc/require-property */ /** * Group: Path edit functions. * Functions relating to editing path elements. * @namespace {PlainObject} pathActions * @memberof module:path */ export const pathActions = (function () { /* eslint-enable jsdoc/require-property */ let subpath = false; let newPoint, 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), 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 = 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 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; 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, y = mouseY / currentZoom, 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' }); stretchy = getElem('selectorParentGroup').appendChild(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; 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, 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 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); if (sSeg.pathSegType === 4) { newseg = drawnPath.createSVGPathSegLinetoAbs(absX, absY); } else { newseg = 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).attr('d'); $(path.elem).attr('d', origD + newD); $(newpath).remove(); if (path.matrix) { recalcRotatedPath(); } init(); pathActions.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 (!$.contains( editorContext_.getContainer(), editorContext_.getMouseTarget(evt) )) { // Clicked outside canvas, so don't make point // console.log('Clicked outside canvas'); return false; } const num = drawnPath.pathSegList.numberOfItems; const last = drawnPath.pathSegList.getItem(num - 1); const lastx = last.x, 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); if (sSeg.pathSegType === 4) { newseg = drawnPath.createSVGPathSegLinetoAbs( editorContext_.round(x), editorContext_.round(y) ); } else { newseg = 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; } 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 = 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) { 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 = addCtrlGrip('1c1'); const pointGrip2 = 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 = 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; } 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); replacePathSeg(6, 1, [mouseX, mouseY, prevX * currentZoom, prevY * currentZoom, mouseX, mouseY], stretchy); } else if (firstCtrl) { replacePathSeg(6, 1, [mouseX, mouseY, firstCtrl[0], firstCtrl[1], mouseX, mouseY], stretchy); } else { replacePathSeg(4, 1, [mouseX, mouseY], stretchy); } } } return; } // if we are dragging a point, let's move it if (path.dragging) { const pt = getPointFromGrip({ x: path.dragging[0], y: path.dragging[1] }, path); const mpt = 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; // eslint-disable-line consistent-this if (!seg.next && !seg.prev) { return; } // const {item} = seg; const rubberBox = editorContext_.getRubberBox(); const rbb = rubberBox.getBBox(); const pt = 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) { 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) { pathActions.toSelectMode(evt.target); } // else, move back to select mode } else { pathActions.toSelectMode(evt.target); } hasMoved = false; return undefined; }, /** * @param {Element} element * @returns {void} */ toEditMode (element) { path = getPath_(element); editorContext_.setCurrentMode('pathedit'); editorContext_.clearSelection(); path.show(true).update(); path.oldbbox = utilsGetBBox(path.elem); subpath = false; }, /** * @param {Element} elem * @fires module:svgcanvas.SvgCanvas#event:selected * @returns {void} */ toSelectMode (elem) { const selPath = (elem === path.elem); editorContext_.setCurrentMode('select'); path.show(false); currentPath = false; editorContext_.clearSelection(); if (path.matrix) { // Rotated, so may need to re-calculate the center recalcRotatedPath(); } if (selPath) { editorContext_.call('selected', [elem]); editorContext_.addToSelection([elem], true); } }, /** * @param {boolean} on * @returns {void} */ addSubPath (on) { 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 { pathActions.clear(true); pathActions.toEditMode(path.elem); } }, /** * @param {Element} target * @returns {void} */ select (target) { if (currentPath === target) { pathActions.toEditMode(target); editorContext_.setCurrentMode('pathedit'); // going into pathedit mode } else { currentPath = target; } }, /** * @fires module:svgcanvas.SvgCanvas#event:changed * @returns {void} */ reorient () { 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 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 (remove) { const drawnPath = editorContext_.getDrawnPath(); currentPath = null; if (drawnPath) { const elem = getElem(editorContext_.getId()); $(getElem('path_stretch_line')).remove(); $(elem).remove(); $(getElem('pathpointgrip_container')).find('*').attr('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 = getTransformList(pth); 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 = []; $.each(['', 1, 2], function (j, n) { const x = seg['x' + n], y = seg['y' + n]; if (x !== undefined && y !== undefined) { const pt = transformPoint(x, y, m); pts.splice(pts.length, 0, pt.x, pt.y); } }); replacePathSeg(type, i, pts, pth); } reorientGrads(pth, m); return undefined; }, /** * @returns {void} */ zoomChange () { 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) { 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 { insertItemBefore(elem, closer, openPt); insertItemBefore(elem, 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, 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--) { insertItemBefore(elem, list.getItem(lastM), zSeg); } const pt = list.getItem(lastM); // Make this point the new "M" 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 (!pathActions.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) { if (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) { pathActions.toSelectMode(path.elem); 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 const cp = $(path.elem); cp.attr('d', cp.attr('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, /** * @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) { lastM = item; } if (item.pathSegType === 1) { 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); insertItemBefore(elem, newseg, i); // Can this be done better? pathActions.fixEnd(elem); break; } } } if (isWebkit()) { editorContext_.resetD(elem); } }, // 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