UNPKG

svgedit

Version:

Powerful SVG-Editor for your browser

1,013 lines (915 loc) 25.3 kB
/** * Path functionality. * @module path * @license MIT * * @copyright 2011 Alexis Deveria, 2011 Jeff Schiller */ import { NS } from './namespaces.js' import { ChangeElementCommand } from './history.js' import { transformPoint, getMatrix } from './math.js' import { assignAttributes, getRotationAngle, getElement } from './utilities.js' let svgCanvas = null /** * @function module:path-actions.init * @param {module:path-actions.svgCanvas} pathMethodsContext * @returns {void} */ export const init = (canvas) => { svgCanvas = canvas } /* eslint-disable max-len */ /** * @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} */ /* eslint-enable max-len */ export const ptObjToArrMethod = function (type, segItem) { const segData = svgCanvas.getSegData() 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 getGripPtMethod = 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 zoom = svgCanvas.getZoom() out.x *= zoom out.y *= zoom return out } /** * @function module:path.getPointFromGrip * @param {module:math.XYObject} pt * @param {module:path.Path} pth * @returns {module:math.XYObject} */ export const getPointFromGripMethod = 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 zoom = svgCanvas.getZoom() out.x /= zoom out.y /= zoom return out } /** * @function module:path.getGripContainer * @returns {Element} */ export const getGripContainerMethod = function () { let c = getElement('pathpointgrip_container') if (!c) { const parentElement = getElement('selectorParentGroup') c = document.createElementNS(NS.SVG, 'g') parentElement.append(c) c.id = 'pathpointgrip_container' } return c } /** * 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 addPointGripMethod = function (index, x, y) { // create the container of all the point grips const pointGripContainer = getGripContainerMethod() let pointGrip = getElement('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' } const uiStrings = svgCanvas.getUIStrings() if ('pathNodeTooltip' in uiStrings) { // May be empty if running path.js without svg-editor atts['xlink:title'] = uiStrings.pathNodeTooltip } assignAttributes(pointGrip, atts) pointGripContainer.append(pointGrip) const grip = document.getElementById('pathpointgrip_' + index) grip?.addEventListener('dblclick', () => { const path = svgCanvas.getPathObj() 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 } /** * 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 addCtrlGripMethod = function (id) { let pointGrip = getElement('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' } const uiStrings = svgCanvas.getUIStrings() if ('pathCtrlPtTooltip' in uiStrings) { // May be empty if running path.js without svg-editor atts['xlink:title'] = uiStrings.pathCtrlPtTooltip } assignAttributes(pointGrip, atts) getGripContainerMethod().append(pointGrip) return pointGrip } /** * @function module:path.getCtrlLine * @param {string} id * @returns {SVGLineElement} */ export const getCtrlLineMethod = function (id) { let ctrlLine = getElement('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' }) getGripContainerMethod().append(ctrlLine) return ctrlLine } /** * @function module:path.getPointGrip * @param {Segment} seg * @param {boolean} update * @returns {SVGCircleElement} */ export const getPointGripMethod = function (seg, update) { const { index } = seg const pointGrip = addPointGripMethod(index) if (update) { const pt = getGripPtMethod(seg) assignAttributes(pointGrip, { cx: pt.x, cy: pt.y, display: 'inline' }) } return pointGrip } /** * @function module:path.getControlPoints * @param {Segment} seg * @returns {PlainObject<string, SVGLineElement|SVGCircleElement>} */ export const getControlPointsMethod = function (seg) { const { item, index } = seg if (!('x1' in item) || !('x2' in item)) { return null } const cpt = {} /* const pointGripContainer = */ getGripContainerMethod() // Note that this is intentionally not seg.prev.item const path = svgCanvas.getPathObj() 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'] = getCtrlLineMethod(id) const pt = getGripPtMethod(seg, { x: item['x' + i], y: item['y' + i] }) const gpt = getGripPtMethod(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] = addCtrlGripMethod(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 replacePathSegMethod = function (type, index, pts, elem) { const path = svgCanvas.getPathObj() const pth = elem || path.elem const pathFuncs = svgCanvas.getPathFuncs() const func = 'createSVGPathSeg' + pathFuncs[type] const seg = pth[func](...pts) pth.pathSegList.replaceItem(seg, index) } /** * @function module:path.getSegSelector * @param {Segment} seg * @param {boolean} update * @returns {SVGPathElement} */ export const getSegSelectorMethod = function (seg, update) { const { index } = seg let segLine = getElement('segline_' + index) if (!segLine) { const pointGripContainer = getGripContainerMethod() // 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 = getGripPtMethod(prev) // Set start point replacePathSegMethod(2, 0, [pt.x, pt.y], segLine) const pts = ptObjToArrMethod(seg.type, seg.item) // , true); for (let i = 0; i < pts.length; i += 2) { const point = getGripPtMethod(seg, { x: pts[i], y: pts[i + 1] }) pts[i] = point.x pts[i + 1] = point.y } replacePathSegMethod(seg.type, 1, pts, segLine) } return segLine } /** * */ 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) { document.getElementById('ctrlpointgrip_' + this.index + 'c1').setAttribute('fill', y ? '#0FF' : '#EEE') document.getElementById('ctrlpointgrip_' + this.index + 'c2').setAttribute('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 = getPointGripMethod(this, true) this.ctrlpts = getControlPointsMethod(this) // , true); this.segsel = getSegSelectorMethod(this, true) } /** * @param {boolean} full * @returns {void} */ update (full) { if (this.ptgrip) { const pt = getGripPtMethod(this) assignAttributes(this.ptgrip, { cx: pt.x, cy: pt.y }) getSegSelectorMethod(this, true) if (this.ctrlpts) { if (full) { const path = svgCanvas.getPathObj() this.item = path.elem.pathSegList.getItem(this.index) this.type = this.item.pathSegType } getControlPointsMethod(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] replacePathSegMethod( this.type, this.index, // type 10 means ARC this.type === 10 ? ptObjToArrMethod(this.type, item) : curPts ) if (this.next?.ctrlpts) { const next = this.next.item const nextPts = [ next.x, next.y, next.x1 += dx, next.y1 += dy, next.x2, next.y2 ] replacePathSegMethod(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] replacePathSegMethod(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; let anum; let 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 ] replacePathSegMethod(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 ] replacePathSegMethod(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) { replacePathSegMethod(newType, this.index, pts) this.type = newType const path = svgCanvas.getPathObj() this.item = path.elem.pathSegList.getItem(this.index) this.showCtrlPts(newType === 6) this.ctrlpts = getControlPointsMethod(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 <path> element') } this.elem = elem this.segs = [] this.selected_pts = [] svgCanvas.setPathObj(this) // path = this; this.init() } setPathContext () { svgCanvas.setPathObj(this) } /** * Reset path data. * @returns {module:path.Path} */ init () { // Hide all grips, etc // fixed, needed to work on all found elements, not just first const pointGripContainer = getGripContainerMethod() const elements = pointGripContainer.querySelectorAll('*') Array.prototype.forEach.call(elements, function (el) { el.setAttribute('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?.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 (!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; let newX; let 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] replacePathSegMethod(seg.type, index, pts) break } } const list = this.elem.pathSegList list.insertItemBefore(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] replacePathSegMethod(2, next.index, pt) // Reposition last node replacePathSegMethod(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] replacePathSegMethod(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 (svgCanvas.getLinkControlPts()) { 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) } const path = svgCanvas.getPathObj() path.endChanges(text) } /** * @param {Integer} pt * @param {Integer} ctrlNum * @returns {void} */ selectPt (pt, ctrlNum) { this.clearSelection() if (!pt) { this.eachSeg(function (i) { // 'this' is the segment here. if (this.prev) { pt = i } }) } this.addPtsToSelection(pt) if (ctrlNum) { this.dragctrl = ctrlNum if (svgCanvas.getLinkControlPts()) { 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) { const cmd = new ChangeElementCommand(this.elem, { d: this.last_d }, text) svgCanvas.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 && !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]) svgCanvas.addPtsToSelection({ grips, closedSubpath }) } // STATIC /** * @param {Integer} index * @returns {boolean} */ static subpathIsClosed (index) { let clsd = false // Check if subpath is already open const path = svgCanvas.getPathObj() 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 } }