/* globals jQuery */ /** * Miscellaneous utilities. * @module utilities * @license MIT * * @copyright 2010 Alexis Deveria, 2010 Jeff Schiller */ import './svgpathseg.js'; import jQueryPluginSVG from './jQuery.attr.js'; // Needed for SVG attribute setting and array form with `attr` import {NS} from './namespaces.js'; import {getTransformList} from './svgtransformlist.js'; import {setUnitAttr, getTypeMap} from './units.js'; import {convertPath} from './path.js'; import { hasMatrixTransform, transformListToTransform, transformBox } from './math.js'; import { isWebkit, supportsHVLineContainerBBox, supportsPathBBox, supportsXpath, supportsSelectors } from './browser.js'; // Constants const $ = jQueryPluginSVG(jQuery); // String used to encode base64. const KEYSTR = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; // Much faster than running getBBox() every time const visElems = 'a,circle,ellipse,foreignObject,g,image,line,path,polygon,polyline,rect,svg,text,tspan,use'; const visElemsArr = visElems.split(','); // const hidElems = 'clipPath,defs,desc,feGaussianBlur,filter,linearGradient,marker,mask,metadata,pattern,radialGradient,stop,switch,symbol,title,textPath'; let editorContext_ = null; let domdoc_ = null; let domcontainer_ = null; let svgroot_ = null; /** * Object with the following keys/values. * @typedef {PlainObject} module:utilities.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:utilities.SVGElementJSON[]} [children] - Data objects to be added recursively as children * @property {string} [namespace="http://www.w3.org/2000/svg"] - Indicate a (non-SVG) namespace */ /** * An object that creates SVG elements for the canvas. * * @interface module:utilities.EditorContext * @property {module:path.pathActions} pathActions */ /** * @function module:utilities.EditorContext#getSVGContent * @returns {SVGSVGElement} */ /** * Create a new SVG element based on the given object keys/values and add it * to the current layer. * The element will be run through `cleanupElement` before being returned. * @function module:utilities.EditorContext#addSVGElementFromJson * @param {module:utilities.SVGElementJSON} data * @returns {Element} The new element */ /** * @function module:utilities.EditorContext#getSelectedElements * @returns {Element[]} the array with selected DOM elements */ /** * @function module:utilities.EditorContext#getDOMDocument * @returns {HTMLDocument} */ /** * @function module:utilities.EditorContext#getDOMContainer * @returns {HTMLElement} */ /** * @function module:utilities.EditorContext#getSVGRoot * @returns {SVGSVGElement} */ /** * @function module:utilities.EditorContext#getBaseUnit * @returns {string} */ /** * @function module:utilities.EditorContext#getSnappingStep * @returns {Float|string} */ /** * @function module:utilities.init * @param {module:utilities.EditorContext} editorContext * @returns {void} */ export const init = function (editorContext) { editorContext_ = editorContext; domdoc_ = editorContext.getDOMDocument(); domcontainer_ = editorContext.getDOMContainer(); svgroot_ = editorContext.getSVGRoot(); }; /** * Used to prevent the [Billion laughs attack]{@link https://en.wikipedia.org/wiki/Billion_laughs_attack}. * @function module:utilities.dropXMLInternalSubset * @param {string} str String to be processed * @returns {string} The string with entity declarations in the internal subset removed * @todo This might be needed in other places `parseFromString` is used even without LGTM flagging */ export const dropXMLInternalSubset = (str) => { return str.replace(/()/, '$1$2'); // return str.replace(/(?\?\]>)/, '$$'); }; /** * Converts characters in a string to XML-friendly entities. * @function module:utilities.toXml * @example `&` becomes `&` * @param {string} str - The string to be converted * @returns {string} The converted string */ export const toXml = function (str) { // ' is ok in XML, but not HTML // > does not normally need escaping, though it can if within a CDATA expression (and preceded by "]]") return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); // Note: `'` is XML only }; /** * Converts XML entities in a string to single characters. * @function module:utilities.fromXml * @example `&` becomes `&` * @param {string} str - The string to be converted * @returns {string} The converted string */ export function fromXml (str) { return $('

').html(str).text(); } // This code was written by Tyler Akins and has been placed in the // public domain. It would be nice if you left this header intact. // Base64 code from Tyler Akins -- http://rumkin.com // schiller: Removed string concatenation in favour of Array.join() optimization, // also precalculate the size of the array needed. /** * Converts a string to base64. * @function module:utilities.encode64 * @param {string} input * @returns {string} Base64 output */ export function encode64 (input) { // base64 strings are 4/3 larger than the original string input = encodeUTF8(input); // convert non-ASCII characters // input = convertToXMLReferences(input); if (window.btoa) { return window.btoa(input); // Use native if available } const output = new Array(Math.floor((input.length + 2) / 3) * 4); let i = 0, p = 0; do { const chr1 = input.charCodeAt(i++); const chr2 = input.charCodeAt(i++); const chr3 = input.charCodeAt(i++); /* eslint-disable no-bitwise */ const enc1 = chr1 >> 2; const enc2 = ((chr1 & 3) << 4) | (chr2 >> 4); let enc3 = ((chr2 & 15) << 2) | (chr3 >> 6); let enc4 = chr3 & 63; /* eslint-enable no-bitwise */ if (Number.isNaN(chr2)) { enc3 = 64; enc4 = 64; } else if (Number.isNaN(chr3)) { enc4 = 64; } output[p++] = KEYSTR.charAt(enc1); output[p++] = KEYSTR.charAt(enc2); output[p++] = KEYSTR.charAt(enc3); output[p++] = KEYSTR.charAt(enc4); } while (i < input.length); return output.join(''); } /** * Converts a string from base64. * @function module:utilities.decode64 * @param {string} input Base64-encoded input * @returns {string} Decoded output */ export function decode64 (input) { if (window.atob) { return decodeUTF8(window.atob(input)); } // remove all characters that are not A-Z, a-z, 0-9, +, /, or = input = input.replace(/[^A-Za-z\d+/=]/g, ''); let output = ''; let i = 0; do { const enc1 = KEYSTR.indexOf(input.charAt(i++)); const enc2 = KEYSTR.indexOf(input.charAt(i++)); const enc3 = KEYSTR.indexOf(input.charAt(i++)); const enc4 = KEYSTR.indexOf(input.charAt(i++)); /* eslint-disable no-bitwise */ const chr1 = (enc1 << 2) | (enc2 >> 4); const chr2 = ((enc2 & 15) << 4) | (enc3 >> 2); const chr3 = ((enc3 & 3) << 6) | enc4; /* eslint-enable no-bitwise */ output += String.fromCharCode(chr1); if (enc3 !== 64) { output += String.fromCharCode(chr2); } if (enc4 !== 64) { output += String.fromCharCode(chr3); } } while (i < input.length); return decodeUTF8(output); } /** * @function module:utilities.decodeUTF8 * @param {string} argString * @returns {string} */ export function decodeUTF8 (argString) { return decodeURIComponent(escape(argString)); } // codedread:does not seem to work with webkit-based browsers on OSX // Brettz9: please test again as function upgraded /** * @function module:utilities.encodeUTF8 * @param {string} argString * @returns {string} */ export const encodeUTF8 = function (argString) { return unescape(encodeURIComponent(argString)); }; /** * Convert dataURL to object URL. * @function module:utilities.dataURLToObjectURL * @param {string} dataurl * @returns {string} object URL or empty string */ export const dataURLToObjectURL = function (dataurl) { if (typeof Uint8Array === 'undefined' || typeof Blob === 'undefined' || typeof URL === 'undefined' || !URL.createObjectURL) { return ''; } const arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1], bstr = atob(arr[1]); /* const [prefix, suffix] = dataurl.split(','), {groups: {mime}} = prefix.match(/:(?.*?);/), bstr = atob(suffix); */ let n = bstr.length; const u8arr = new Uint8Array(n); while (n--) { u8arr[n] = bstr.charCodeAt(n); } const blob = new Blob([u8arr], {type: mime}); return URL.createObjectURL(blob); }; /** * Get object URL for a blob object. * @function module:utilities.createObjectURL * @param {Blob} blob A Blob object or File object * @returns {string} object URL or empty string */ export const createObjectURL = function (blob) { if (!blob || typeof URL === 'undefined' || !URL.createObjectURL) { return ''; } return URL.createObjectURL(blob); }; /** * @property {string} blankPageObjectURL */ export const blankPageObjectURL = (function () { if (typeof Blob === 'undefined') { return ''; } const blob = new Blob(['SVG-edit '], {type: 'text/html'}); return createObjectURL(blob); })(); /** * Converts a string to use XML references (for non-ASCII). * @function module:utilities.convertToXMLReferences * @param {string} input * @returns {string} Decimal numeric character references */ export const convertToXMLReferences = function (input) { let output = ''; [...input].forEach((ch) => { const c = ch.charCodeAt(); if (c <= 127) { output += ch; } else { output += `&#${c};`; } }); return output; }; /** * Cross-browser compatible method of converting a string to an XML tree. * Found this function [here]{@link http://groups.google.com/group/jquery-dev/browse_thread/thread/c6d11387c580a77f}. * @function module:utilities.text2xml * @param {string} sXML * @throws {Error} * @returns {XMLDocument} */ export const text2xml = function (sXML) { if (sXML.includes('` * - `` * - `` * @function module:utilities.getUrlFromAttr * @param {string} attrVal The attribute value as a string * @returns {string} String with just the URL, like "someFile.svg#foo" */ export const getUrlFromAttr = function (attrVal) { if (attrVal) { // url('#somegrad') if (attrVal.startsWith('url("')) { return attrVal.substring(5, attrVal.indexOf('"', 6)); } // url('#somegrad') if (attrVal.startsWith("url('")) { return attrVal.substring(5, attrVal.indexOf("'", 6)); } if (attrVal.startsWith('url(')) { return attrVal.substring(4, attrVal.indexOf(')')); } } return null; }; /** * @function module:utilities.getHref * @param {Element} elem * @returns {string} The given element's `xlink:href` value */ export let getHref = function (elem) { // eslint-disable-line import/no-mutable-exports return elem.getAttributeNS(NS.XLINK, 'href'); }; /** * Sets the given element's `xlink:href` value. * @function module:utilities.setHref * @param {Element} elem * @param {string} val * @returns {void} */ export let setHref = function (elem, val) { // eslint-disable-line import/no-mutable-exports elem.setAttributeNS(NS.XLINK, 'xlink:href', val); }; /** * @function module:utilities.findDefs * @returns {SVGDefsElement} The document's `` element, creating it first if necessary */ export const findDefs = function () { const svgElement = editorContext_.getSVGContent(); let defs = svgElement.getElementsByTagNameNS(NS.SVG, 'defs'); if (defs.length > 0) { defs = defs[0]; } else { defs = svgElement.ownerDocument.createElementNS(NS.SVG, 'defs'); if (svgElement.firstChild) { // first child is a comment, so call nextSibling svgElement.insertBefore(defs, svgElement.firstChild.nextSibling); // svgElement.firstChild.nextSibling.before(defs); // Not safe } else { svgElement.append(defs); } } return defs; }; // TODO(codedread): Consider moving the next to functions to bbox.js /** * Get correct BBox for a path in Webkit. * Converted from code found [here]{@link http://blog.hackers-cafe.net/2009/06/how-to-calculate-bezier-curves-bounding.html}. * @function module:utilities.getPathBBox * @param {SVGPathElement} path - The path DOM element to get the BBox for * @returns {module:utilities.BBoxObject} A BBox-like object */ export const getPathBBox = function (path) { const seglist = path.pathSegList; const tot = seglist.numberOfItems; const bounds = [[], []]; const start = seglist.getItem(0); let P0 = [start.x, start.y]; const getCalc = function (j, P1, P2, P3) { return function (t) { return 1 - (t ** 3) * P0[j] + 3 * 1 - (t ** 2) * t * P1[j] + 3 * (1 - t) * (t ** 2) * P2[j] + (t ** 3) * P3[j]; }; }; for (let i = 0; i < tot; i++) { const seg = seglist.getItem(i); if (seg.x === undefined) { continue; } // Add actual points to limits bounds[0].push(P0[0]); bounds[1].push(P0[1]); if (seg.x1) { const P1 = [seg.x1, seg.y1], P2 = [seg.x2, seg.y2], P3 = [seg.x, seg.y]; for (let j = 0; j < 2; j++) { const calc = getCalc(j, P1, P2, P3); const b = 6 * P0[j] - 12 * P1[j] + 6 * P2[j]; const a = -3 * P0[j] + 9 * P1[j] - 9 * P2[j] + 3 * P3[j]; const c = 3 * P1[j] - 3 * P0[j]; if (a === 0) { if (b === 0) { continue; } const t = -c / b; if (t > 0 && t < 1) { bounds[j].push(calc(t)); } continue; } const b2ac = (b ** 2) - 4 * c * a; if (b2ac < 0) { continue; } const t1 = (-b + Math.sqrt(b2ac)) / (2 * a); if (t1 > 0 && t1 < 1) { bounds[j].push(calc(t1)); } const t2 = (-b - Math.sqrt(b2ac)) / (2 * a); if (t2 > 0 && t2 < 1) { bounds[j].push(calc(t2)); } } P0 = P3; } else { bounds[0].push(seg.x); bounds[1].push(seg.y); } } const x = Math.min.apply(null, bounds[0]); const w = Math.max.apply(null, bounds[0]) - x; const y = Math.min.apply(null, bounds[1]); const h = Math.max.apply(null, bounds[1]) - y; return { x, y, width: w, height: h }; }; /** * Get the given/selected element's bounding box object, checking for * horizontal/vertical lines (see issue 717) * Note that performance is currently terrible, so some way to improve would * be great. * @param {Element} selected - Container or `` DOM element * @returns {DOMRect} Bounding box object */ function groupBBFix (selected) { if (supportsHVLineContainerBBox()) { try { return selected.getBBox(); } catch (e) {} } const ref = $.data(selected, 'ref'); let matched = null; let ret, copy; if (ref) { copy = $(ref).children().clone().attr('visibility', 'hidden'); $(svgroot_).append(copy); matched = copy.filter('line, path'); } else { matched = $(selected).find('line, path'); } let issue = false; if (matched.length) { matched.each(function () { const bb = this.getBBox(); if (!bb.width || !bb.height) { issue = true; } }); if (issue) { const elems = ref ? copy : $(selected).children(); ret = getStrokedBBox(elems); } else { ret = selected.getBBox(); } } else { ret = selected.getBBox(); } if (ref) { copy.remove(); } return ret; } /** * Get the given/selected element's bounding box object, convert it to be more * usable when necessary. * @function module:utilities.getBBox * @param {Element} elem - Optional DOM element to get the BBox for * @returns {module:utilities.BBoxObject} Bounding box object */ export const getBBox = function (elem) { const selected = elem || editorContext_.geSelectedElements()[0]; if (elem.nodeType !== 1) { return null; } const elname = selected.nodeName; let ret = null; switch (elname) { case 'text': if (selected.textContent === '') { selected.textContent = 'a'; // Some character needed for the selector to use. ret = selected.getBBox(); selected.textContent = ''; } else if (selected.getBBox) { ret = selected.getBBox(); } break; case 'path': if (!supportsPathBBox()) { ret = getPathBBox(selected); } else if (selected.getBBox) { ret = selected.getBBox(); } break; case 'g': case 'a': ret = groupBBFix(selected); break; default: if (elname === 'use') { ret = groupBBFix(selected); // , true); } if (elname === 'use' || (elname === 'foreignObject' && isWebkit())) { if (!ret) { ret = selected.getBBox(); } // This is resolved in later versions of webkit, perhaps we should // have a featured detection for correct 'use' behavior? // —————————— if (!isWebkit()) { const {x, y, width, height} = ret; const bb = { width, height, x: x + Number.parseFloat(selected.getAttribute('x') || 0), y: y + Number.parseFloat(selected.getAttribute('y') || 0) }; ret = bb; } } else if (visElemsArr.includes(elname)) { if (selected) { try { ret = selected.getBBox(); } catch (err) { // tspan (and textPath apparently) have no `getBBox` in Firefox: https://bugzilla.mozilla.org/show_bug.cgi?id=937268 // Re: Chrome returning bbox for containing text element, see: https://bugs.chromium.org/p/chromium/issues/detail?id=349835 const extent = selected.getExtentOfChar(0); // pos+dimensions of the first glyph const width = selected.getComputedTextLength(); // width of the tspan ret = { x: extent.x, y: extent.y, width, height: extent.height }; } } else { // Check if element is child of a foreignObject const fo = $(selected).closest('foreignObject'); if (fo.length) { if (fo[0].getBBox) { ret = fo[0].getBBox(); } } } } } if (ret) { ret = bboxToObj(ret); } // get the bounding box from the DOM (which is in that element's coordinate system) return ret; }; /** * @typedef {GenericArray} module:utilities.PathSegmentArray * @property {Integer} length 2 * @property {"M"|"L"|"C"|"Z"} 0 * @property {Float[]} 1 */ /** * Create a path 'd' attribute from path segments. * Each segment is an array of the form: `[singleChar, [x,y, x,y, ...]]` * @function module:utilities.getPathDFromSegments * @param {module:utilities.PathSegmentArray[]} pathSegments - An array of path segments to be converted * @returns {string} The converted path d attribute. */ export const getPathDFromSegments = function (pathSegments) { let d = ''; $.each(pathSegments, function (j, [singleChar, pts]) { d += singleChar; for (let i = 0; i < pts.length; i += 2) { d += (pts[i] + ',' + pts[i + 1]) + ' '; } }); return d; }; /** * Make a path 'd' attribute from a simple SVG element shape. * @function module:utilities.getPathDFromElement * @param {Element} elem - The element to be converted * @returns {string} The path d attribute or `undefined` if the element type is unknown. */ export const getPathDFromElement = function (elem) { // Possibly the cubed root of 6, but 1.81 works best let num = 1.81; let d, a, rx, ry; switch (elem.tagName) { case 'ellipse': case 'circle': { a = $(elem).attr(['rx', 'ry', 'cx', 'cy']); const {cx, cy} = a; ({rx, ry} = a); if (elem.tagName === 'circle') { ry = $(elem).attr('r'); rx = ry; } d = getPathDFromSegments([ ['M', [(cx - rx), (cy)]], ['C', [(cx - rx), (cy - ry / num), (cx - rx / num), (cy - ry), (cx), (cy - ry)]], ['C', [(cx + rx / num), (cy - ry), (cx + rx), (cy - ry / num), (cx + rx), (cy)]], ['C', [(cx + rx), (cy + ry / num), (cx + rx / num), (cy + ry), (cx), (cy + ry)]], ['C', [(cx - rx / num), (cy + ry), (cx - rx), (cy + ry / num), (cx - rx), (cy)]], ['Z', []] ]); break; } case 'path': d = elem.getAttribute('d'); break; case 'line': a = $(elem).attr(['x1', 'y1', 'x2', 'y2']); d = 'M' + a.x1 + ',' + a.y1 + 'L' + a.x2 + ',' + a.y2; break; case 'polyline': d = 'M' + elem.getAttribute('points'); break; case 'polygon': d = 'M' + elem.getAttribute('points') + ' Z'; break; case 'rect': { const r = $(elem).attr(['rx', 'ry']); ({rx, ry} = r); const b = elem.getBBox(); const {x, y} = b, w = b.width, h = b.height; num = 4 - num; // Why? Because! if (!rx && !ry) { // Regular rect d = getPathDFromSegments([ ['M', [x, y]], ['L', [x + w, y]], ['L', [x + w, y + h]], ['L', [x, y + h]], ['L', [x, y]], ['Z', []] ]); } else { d = getPathDFromSegments([ ['M', [x, y + ry]], ['C', [x, y + ry / num, x + rx / num, y, x + rx, y]], ['L', [x + w - rx, y]], ['C', [x + w - rx / num, y, x + w, y + ry / num, x + w, y + ry]], ['L', [x + w, y + h - ry]], ['C', [x + w, y + h - ry / num, x + w - rx / num, y + h, x + w - rx, y + h]], ['L', [x + rx, y + h]], ['C', [x + rx / num, y + h, x, y + h - ry / num, x, y + h - ry]], ['L', [x, y + ry]], ['Z', []] ]); } break; } default: break; } return d; }; /** * Get a set of attributes from an element that is useful for convertToPath. * @function module:utilities.getExtraAttributesForConvertToPath * @param {Element} elem - The element to be probed * @returns {PlainObject<"marker-start"|"marker-end"|"marker-mid"|"filter"|"clip-path", string>} An object with attributes. */ export const getExtraAttributesForConvertToPath = function (elem) { const attrs = {}; // TODO: make this list global so that we can properly maintain it // TODO: what about @transform, @clip-rule, @fill-rule, etc? $.each(['marker-start', 'marker-end', 'marker-mid', 'filter', 'clip-path'], function () { const a = elem.getAttribute(this); if (a) { attrs[this] = a; } }); return attrs; }; /** * Get the BBox of an element-as-path. * @function module:utilities.getBBoxOfElementAsPath * @param {Element} elem - The DOM element to be probed * @param {module:utilities.EditorContext#addSVGElementFromJson} addSVGElementFromJson - Function to add the path element to the current layer. See canvas.addSVGElementFromJson * @param {module:path.pathActions} pathActions - If a transform exists, `pathActions.resetOrientation()` is used. See: canvas.pathActions. * @returns {DOMRect|false} The resulting path's bounding box object. */ export const getBBoxOfElementAsPath = function (elem, addSVGElementFromJson, pathActions) { const path = addSVGElementFromJson({ element: 'path', attr: getExtraAttributesForConvertToPath(elem) }); const eltrans = elem.getAttribute('transform'); if (eltrans) { path.setAttribute('transform', eltrans); } const {parentNode} = elem; if (elem.nextSibling) { elem.before(path); } else { parentNode.append(path); } const d = getPathDFromElement(elem); if (d) { path.setAttribute('d', d); } else { path.remove(); } // Get the correct BBox of the new path, then discard it pathActions.resetOrientation(path); let bb = false; try { bb = path.getBBox(); } catch (e) { // Firefox fails } path.remove(); return bb; }; /** * Convert selected element to a path. * @function module:utilities.convertToPath * @param {Element} elem - The DOM element to be converted * @param {module:utilities.SVGElementJSON} attrs - Apply attributes to new path. see canvas.convertToPath * @param {module:utilities.EditorContext#addSVGElementFromJson} addSVGElementFromJson - Function to add the path element to the current layer. See canvas.addSVGElementFromJson * @param {module:path.pathActions} pathActions - If a transform exists, pathActions.resetOrientation() is used. See: canvas.pathActions. * @param {module:draw.DrawCanvasInit#clearSelection|module:path.EditorContext#clearSelection} clearSelection - see [canvas.clearSelection]{@link module:svgcanvas.SvgCanvas#clearSelection} * @param {module:path.EditorContext#addToSelection} addToSelection - see [canvas.addToSelection]{@link module:svgcanvas.SvgCanvas#addToSelection} * @param {module:history} hstry - see history module * @param {module:path.EditorContext#addCommandToHistory|module:draw.DrawCanvasInit#addCommandToHistory} addCommandToHistory - see [canvas.addCommandToHistory]{@link module:svgcanvas~addCommandToHistory} * @returns {SVGPathElement|null} The converted path element or null if the DOM element was not recognized. */ export const convertToPath = function (elem, attrs, addSVGElementFromJson, pathActions, clearSelection, addToSelection, hstry, addCommandToHistory) { const batchCmd = new hstry.BatchCommand('Convert element to Path'); // Any attribute on the element not covered by the passed-in attributes attrs = $.extend({}, attrs, getExtraAttributesForConvertToPath(elem)); const path = addSVGElementFromJson({ element: 'path', attr: attrs }); const eltrans = elem.getAttribute('transform'); if (eltrans) { path.setAttribute('transform', eltrans); } const {id} = elem; const {parentNode} = elem; if (elem.nextSibling) { elem.before(path); } else { parentNode.append(path); } const d = getPathDFromElement(elem); if (d) { path.setAttribute('d', d); // Replace the current element with the converted one // Reorient if it has a matrix if (eltrans) { const tlist = getTransformList(path); if (hasMatrixTransform(tlist)) { pathActions.resetOrientation(path); } } const {nextSibling} = elem; batchCmd.addSubCommand(new hstry.RemoveElementCommand(elem, nextSibling, parent)); batchCmd.addSubCommand(new hstry.InsertElementCommand(path)); clearSelection(); elem.remove(); path.setAttribute('id', id); path.removeAttribute('visibility'); addToSelection([path], true); addCommandToHistory(batchCmd); return path; } // the elem.tagName was not recognized, so no "d" attribute. Remove it, so we've haven't changed anything. path.remove(); return null; }; /** * Can the bbox be optimized over the native getBBox? The optimized bbox is the same as the native getBBox when * the rotation angle is a multiple of 90 degrees and there are no complex transforms. * Getting an optimized bbox can be dramatically slower, so we want to make sure it's worth it. * * The best example for this is a circle rotate 45 degrees. The circle doesn't get wider or taller when rotated * about it's center. * * The standard, unoptimized technique gets the native bbox of the circle, rotates the box 45 degrees, uses * that width and height, and applies any transforms to get the final bbox. This means the calculated bbox * is much wider than the original circle. If the angle had been 0, 90, 180, etc. both techniques render the * same bbox. * * The optimization is not needed if the rotation is a multiple 90 degrees. The default technique is to call * getBBox then apply the angle and any transforms. * * @param {Float} angle - The rotation angle in degrees * @param {boolean} hasAMatrixTransform - True if there is a matrix transform * @returns {boolean} True if the bbox can be optimized. */ function bBoxCanBeOptimizedOverNativeGetBBox (angle, hasAMatrixTransform) { const angleModulo90 = angle % 90; const closeTo90 = angleModulo90 < -89.99 || angleModulo90 > 89.99; const closeTo0 = angleModulo90 > -0.001 && angleModulo90 < 0.001; return hasAMatrixTransform || !(closeTo0 || closeTo90); } /** * Get bounding box that includes any transforms. * @function module:utilities.getBBoxWithTransform * @param {Element} elem - The DOM element to be converted * @param {module:utilities.EditorContext#addSVGElementFromJson} addSVGElementFromJson - Function to add the path element to the current layer. See canvas.addSVGElementFromJson * @param {module:path.pathActions} pathActions - If a transform exists, pathActions.resetOrientation() is used. See: canvas.pathActions. * @returns {module:utilities.BBoxObject|module:math.TransformedBox|DOMRect} A single bounding box object */ export const getBBoxWithTransform = function (elem, addSVGElementFromJson, pathActions) { // TODO: Fix issue with rotated groups. Currently they work // fine in FF, but not in other browsers (same problem mentioned // in Issue 339 comment #2). let bb = getBBox(elem); if (!bb) { return null; } const tlist = getTransformList(elem); const angle = getRotationAngleFromTransformList(tlist); const hasMatrixXForm = hasMatrixTransform(tlist); if (angle || hasMatrixXForm) { let goodBb = false; if (bBoxCanBeOptimizedOverNativeGetBBox(angle, hasMatrixXForm)) { // Get the BBox from the raw path for these elements // TODO: why ellipse and not circle const elemNames = ['ellipse', 'path', 'line', 'polyline', 'polygon']; if (elemNames.includes(elem.tagName)) { goodBb = getBBoxOfElementAsPath(elem, addSVGElementFromJson, pathActions); bb = goodBb; } else if (elem.tagName === 'rect') { // Look for radius const rx = elem.getAttribute('rx'); const ry = elem.getAttribute('ry'); if (rx || ry) { goodBb = getBBoxOfElementAsPath(elem, addSVGElementFromJson, pathActions); bb = goodBb; } } } if (!goodBb) { const {matrix} = transformListToTransform(tlist); bb = transformBox(bb.x, bb.y, bb.width, bb.height, matrix).aabox; // Old technique that was exceedingly slow with large documents. // // Accurate way to get BBox of rotated element in Firefox: // Put element in group and get its BBox // // Must use clone else FF freaks out // const clone = elem.cloneNode(true); // const g = document.createElementNS(NS.SVG, 'g'); // const parent = elem.parentNode; // parent.append(g); // g.append(clone); // const bb2 = bboxToObj(g.getBBox()); // g.remove(); } } return bb; }; /** * @param {Element} elem * @returns {Float} * @todo This is problematic with large stroke-width and, for example, a single * horizontal line. The calculated BBox extends way beyond left and right sides. */ function getStrokeOffsetForBBox (elem) { const sw = elem.getAttribute('stroke-width'); return (!isNaN(sw) && elem.getAttribute('stroke') !== 'none') ? sw / 2 : 0; } /** * @typedef {PlainObject} BBox * @property {Integer} x The x value * @property {Integer} y The y value * @property {Float} width * @property {Float} height */ /** * Get the bounding box for one or more stroked and/or transformed elements. * @function module:utilities.getStrokedBBox * @param {Element[]} elems - Array with DOM elements to check * @param {module:utilities.EditorContext#addSVGElementFromJson} addSVGElementFromJson - Function to add the path element to the current layer. See canvas.addSVGElementFromJson * @param {module:path.pathActions} pathActions - If a transform exists, pathActions.resetOrientation() is used. See: canvas.pathActions. * @returns {module:utilities.BBoxObject|module:math.TransformedBox|DOMRect} A single bounding box object */ export const getStrokedBBox = function (elems, addSVGElementFromJson, pathActions) { if (!elems || !elems.length) { return false; } let fullBb; $.each(elems, function () { if (fullBb) { return; } if (!this.parentNode) { return; } fullBb = getBBoxWithTransform(this, addSVGElementFromJson, pathActions); }); // This shouldn't ever happen... if (fullBb === undefined) { return null; } // fullBb doesn't include the stoke, so this does no good! // if (elems.length == 1) return fullBb; let maxX = fullBb.x + fullBb.width; let maxY = fullBb.y + fullBb.height; let minX = fullBb.x; let minY = fullBb.y; // If only one elem, don't call the potentially slow getBBoxWithTransform method again. if (elems.length === 1) { const offset = getStrokeOffsetForBBox(elems[0]); minX -= offset; minY -= offset; maxX += offset; maxY += offset; } else { $.each(elems, function (i, elem) { const curBb = getBBoxWithTransform(elem, addSVGElementFromJson, pathActions); if (curBb) { const offset = getStrokeOffsetForBBox(elem); minX = Math.min(minX, curBb.x - offset); minY = Math.min(minY, curBb.y - offset); // TODO: The old code had this test for max, but not min. I suspect this test should be for both min and max if (elem.nodeType === 1) { maxX = Math.max(maxX, curBb.x + curBb.width + offset); maxY = Math.max(maxY, curBb.y + curBb.height + offset); } } }); } fullBb.x = minX; fullBb.y = minY; fullBb.width = maxX - minX; fullBb.height = maxY - minY; return fullBb; }; /** * Get all elements that have a BBox (excludes ``, ``, etc). * Note that 0-opacity, off-screen etc elements are still considered "visible" * for this function. * @function module:utilities.getVisibleElements * @param {Element} parentElement - The parent DOM element to search within * @returns {Element[]} All "visible" elements. */ export const getVisibleElements = function (parentElement) { if (!parentElement) { parentElement = $(editorContext_.getSVGContent()).children(); // Prevent layers from being included } const contentElems = []; $(parentElement).children().each(function (i, elem) { if (elem.getBBox) { contentElems.push(elem); } }); return contentElems.reverse(); }; /** * Get the bounding box for one or more stroked and/or transformed elements. * @function module:utilities.getStrokedBBoxDefaultVisible * @param {Element[]} elems - Array with DOM elements to check * @returns {module:utilities.BBoxObject} A single bounding box object */ export const getStrokedBBoxDefaultVisible = function (elems) { if (!elems) { elems = getVisibleElements(); } return getStrokedBBox( elems, editorContext_.addSVGElementFromJson, editorContext_.pathActions ); }; /** * Get the rotation angle of the given transform list. * @function module:utilities.getRotationAngleFromTransformList * @param {SVGTransformList} tlist - List of transforms * @param {boolean} toRad - When true returns the value in radians rather than degrees * @returns {Float} The angle in degrees or radians */ export const getRotationAngleFromTransformList = function (tlist, toRad) { if (!tlist) { return 0; } // <svg> elements have no tlist const N = tlist.numberOfItems; for (let i = 0; i < N; ++i) { const xform = tlist.getItem(i); if (xform.type === 4) { return toRad ? xform.angle * Math.PI / 180.0 : xform.angle; } } return 0.0; }; /** * Get the rotation angle of the given/selected DOM element. * @function module:utilities.getRotationAngle * @param {Element} [elem] - DOM element to get the angle for. Default to first of selected elements. * @param {boolean} [toRad=false] - When true returns the value in radians rather than degrees * @returns {Float} The angle in degrees or radians */ export let getRotationAngle = function (elem, toRad) { // eslint-disable-line import/no-mutable-exports const selected = elem || editorContext_.getSelectedElements()[0]; // find the rotation transform (if any) and set it const tlist = getTransformList(selected); return getRotationAngleFromTransformList(tlist, toRad); }; /** * Get the reference element associated with the given attribute value. * @function module:utilities.getRefElem * @param {string} attrVal - The attribute value as a string * @returns {Element} Reference element */ export const getRefElem = function (attrVal) { return getElem(getUrlFromAttr(attrVal).substr(1)); }; /** * Get a DOM element by ID within the SVG root element. * @function module:utilities.getElem * @param {string} id - String with the element's new ID * @returns {?Element} */ export const getElem = (supportsSelectors()) ? function (id) { // querySelector lookup return svgroot_.querySelector('#' + id); } : supportsXpath() ? function (id) { // xpath lookup return domdoc_.evaluate( 'svg:svg[@id="svgroot"]//svg:*[@id="' + id + '"]', domcontainer_, function () { return NS.SVG; }, 9, null ).singleNodeValue; } : function (id) { // jQuery lookup: twice as slow as xpath in FF return $(svgroot_).find('[id=' + id + ']')[0]; }; /** * Assigns multiple attributes to an element. * @function module:utilities.assignAttributes * @param {Element} elem - DOM element to apply new attribute values to * @param {PlainObject<string, string>} attrs - Object with attribute keys/values * @param {Integer} [suspendLength] - Milliseconds to suspend redraw * @param {boolean} [unitCheck=false] - Boolean to indicate the need to use units.setUnitAttr * @returns {void} */ export const assignAttributes = function (elem, attrs, suspendLength, unitCheck) { for (const [key, value] of Object.entries(attrs)) { const ns = (key.substr(0, 4) === 'xml:' ? NS.XML : key.substr(0, 6) === 'xlink:' ? NS.XLINK : null); if (isNullish(value)) { if (ns) { elem.removeAttributeNS(ns, key); } else { elem.removeAttribute(key); } continue; } if (ns) { elem.setAttributeNS(ns, key, value); } else if (!unitCheck) { elem.setAttribute(key, value); } else { setUnitAttr(elem, key, value); } } }; /** * Remove unneeded (default) attributes, making resulting SVG smaller. * @function module:utilities.cleanupElement * @param {Element} element - DOM element to clean up * @returns {void} */ export const cleanupElement = function (element) { const defaults = { 'fill-opacity': 1, 'stop-opacity': 1, opacity: 1, stroke: 'none', 'stroke-dasharray': 'none', 'stroke-linejoin': 'miter', 'stroke-linecap': 'butt', 'stroke-opacity': 1, 'stroke-width': 1, rx: 0, ry: 0 }; if (element.nodeName === 'ellipse') { // Ellipse elements require rx and ry attributes delete defaults.rx; delete defaults.ry; } Object.entries(defaults).forEach(([attr, val]) => { if (element.getAttribute(attr) === String(val)) { element.removeAttribute(attr); } }); }; /** * Round value to for snapping. * @function module:utilities.snapToGrid * @param {Float} value * @returns {Integer} */ export const snapToGrid = function (value) { const unit = editorContext_.getBaseUnit(); let stepSize = editorContext_.getSnappingStep(); if (unit !== 'px') { stepSize *= getTypeMap()[unit]; } value = Math.round(value / stepSize) * stepSize; return value; }; /** * Escapes special characters in a regular expression. * @function module:utilities.regexEscape * @param {string} str * @returns {string} */ export const regexEscape = function (str) { // Originally from: http://phpjs.org/functions return String(str).replace(/[.\\+*?[^\]$(){}=!<>|:-]/g, '\\$&'); }; /** * Prevents default browser click behaviour on the given element. * @function module:utilities.preventClickDefault * @param {Element} img - The DOM element to prevent the click on * @returns {void} */ export const preventClickDefault = function (img) { $(img).click(function (e) { e.preventDefault(); }); }; /** * @callback module:utilities.GetNextID * @returns {string} The ID */ /** * Create a clone of an element, updating its ID and its children's IDs when needed. * @function module:utilities.copyElem * @param {Element} el - DOM element to clone * @param {module:utilities.GetNextID} getNextId - The getter of the next unique ID. * @returns {Element} The cloned element */ export const copyElem = function (el, getNextId) { // manually create a copy of the element const newEl = document.createElementNS(el.namespaceURI, el.nodeName); $.each(el.attributes, function (i, attr) { if (attr.localName !== '-moz-math-font-style') { newEl.setAttributeNS(attr.namespaceURI, attr.nodeName, attr.value); } }); // set the copied element's new id newEl.removeAttribute('id'); newEl.id = getNextId(); // Opera's "d" value needs to be reset for Opera/Win/non-EN // Also needed for webkit (else does not keep curved segments on clone) if (isWebkit() && el.nodeName === 'path') { const fixedD = convertPath(el); newEl.setAttribute('d', fixedD); } // now create copies of all children $.each(el.childNodes, function (i, child) { switch (child.nodeType) { case 1: // element node newEl.append(copyElem(child, getNextId)); break; case 3: // text node newEl.textContent = child.nodeValue; break; default: break; } }); if ($(el).data('gsvg')) { $(newEl).data('gsvg', newEl.firstChild); } else if ($(el).data('symbol')) { const ref = $(el).data('symbol'); $(newEl).data('ref', ref).data('symbol', ref); } else if (newEl.tagName === 'image') { preventClickDefault(newEl); } return newEl; }; /** * Whether a value is `null` or `undefined`. * @param {any} val * @returns {boolean} */ export const isNullish = (val) => { return val === null || val === undefined; }; /** * Overwrite methods for unit testing. * @function module:utilities.mock * @param {PlainObject} mockMethods * @returns {void} */ export const mock = ({ getHref: getHrefUser, setHref: setHrefUser, getRotationAngle: getRotationAngleUser }) => { getHref = getHrefUser; setHref = setHrefUser; getRotationAngle = getRotationAngleUser; };