@svgedit/svgcanvas 7.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/utilities.js ADDED
@@ -0,0 +1,1214 @@
1
+ /**
2
+ * Miscellaneous utilities.
3
+ * @module utilities
4
+ * @license MIT
5
+ *
6
+ * @copyright 2010 Alexis Deveria, 2010 Jeff Schiller
7
+ */
8
+
9
+ import { NS } from './namespaces.js'
10
+ import { setUnitAttr, getTypeMap } from '../../src/common/units.js'
11
+ import {
12
+ hasMatrixTransform, transformListToTransform, transformBox
13
+ } from './math.js'
14
+ import { getClosest, mergeDeep } from '../../src/common/util.js'
15
+
16
+ // Much faster than running getBBox() every time
17
+ const visElems = 'a,circle,ellipse,foreignObject,g,image,line,path,polygon,polyline,rect,svg,text,tspan,use,clipPath'
18
+ const visElemsArr = visElems.split(',')
19
+ // const hidElems = 'defs,desc,feGaussianBlur,filter,linearGradient,marker,mask,metadata,pattern,radialGradient,stop,switch,symbol,title,textPath';
20
+
21
+ let svgCanvas = null
22
+ let svgroot_ = null
23
+
24
+ /**
25
+ * Object with the following keys/values.
26
+ * @typedef {PlainObject} module:utilities.SVGElementJSON
27
+ * @property {string} element - Tag name of the SVG element to create
28
+ * @property {PlainObject<string, string>} attr - Has key-value attributes to assign to the new element.
29
+ * An `id` should be set so that {@link module:utilities.EditorContext#addSVGElementsFromJson} can later re-identify the element for modification or replacement.
30
+ * @property {boolean} [curStyles=false] - Indicates whether current style attributes should be applied first
31
+ * @property {module:utilities.SVGElementJSON[]} [children] - Data objects to be added recursively as children
32
+ * @property {string} [namespace="http://www.w3.org/2000/svg"] - Indicate a (non-SVG) namespace
33
+ */
34
+
35
+ /**
36
+ * An object that creates SVG elements for the canvas.
37
+ *
38
+ * @interface module:utilities.EditorContext
39
+ * @property {module:path.pathActions} pathActions
40
+ */
41
+ /**
42
+ * @function module:utilities.EditorContext#getSvgContent
43
+ * @returns {SVGSVGElement}
44
+ */
45
+ /**
46
+ * Create a new SVG element based on the given object keys/values and add it
47
+ * to the current layer.
48
+ * The element will be run through `cleanupElement` before being returned.
49
+ * @function module:utilities.EditorContext#addSVGElementsFromJson
50
+ * @param {module:utilities.SVGElementJSON} data
51
+ * @returns {Element} The new element
52
+ */
53
+ /**
54
+ * @function module:utilities.EditorContext#getSelectedElements
55
+ * @returns {Element[]} the array with selected DOM elements
56
+ */
57
+ /**
58
+ * @function module:utilities.EditorContext#getDOMDocument
59
+ * @returns {HTMLDocument}
60
+ */
61
+ /**
62
+ * @function module:utilities.EditorContext#getDOMContainer
63
+ * @returns {HTMLElement}
64
+ */
65
+ /**
66
+ * @function module:utilities.EditorContext#getSvgRoot
67
+ * @returns {SVGSVGElement}
68
+ */
69
+ /**
70
+ * @function module:utilities.EditorContext#getBaseUnit
71
+ * @returns {string}
72
+ */
73
+ /**
74
+ * @function module:utilities.EditorContext#getSnappingStep
75
+ * @returns {Float|string}
76
+ */
77
+
78
+ /**
79
+ * @function module:utilities.init
80
+ * @param {module:utilities.EditorContext} canvas
81
+ * @returns {void}
82
+ */
83
+ export const init = (canvas) => {
84
+ svgCanvas = canvas
85
+ svgroot_ = canvas.getSvgRoot()
86
+ }
87
+
88
+ /**
89
+ * Used to prevent the [Billion laughs attack]{@link https://en.wikipedia.org/wiki/Billion_laughs_attack}.
90
+ * @function module:utilities.dropXMLInternalSubset
91
+ * @param {string} str String to be processed
92
+ * @returns {string} The string with entity declarations in the internal subset removed
93
+ * @todo This might be needed in other places `parseFromString` is used even without LGTM flagging
94
+ */
95
+ export const dropXMLInternalSubset = (str) => {
96
+ return str.replace(/(<!DOCTYPE\s+\w*\s*\[).*(\?]>)/, '$1$2')
97
+ // return str.replace(/(?<doctypeOpen><!DOCTYPE\s+\w*\s*\[).*(?<doctypeClose>\?\]>)/, '$<doctypeOpen>$<doctypeClose>');
98
+ }
99
+
100
+ /**
101
+ * Converts characters in a string to XML-friendly entities.
102
+ * @function module:utilities.toXml
103
+ * @example `&` becomes `&amp;`
104
+ * @param {string} str - The string to be converted
105
+ * @returns {string} The converted string
106
+ */
107
+ export const toXml = (str) => {
108
+ // &apos; is ok in XML, but not HTML
109
+ // &gt; does not normally need escaping, though it can if within a CDATA expression (and preceded by "]]")
110
+ return str
111
+ .replace(/&/g, '&amp;')
112
+ .replace(/</g, '&lt;')
113
+ .replace(/>/g, '&gt;')
114
+ .replace(/"/g, '&quot;')
115
+ .replace(/'/g, '&#x27;') // Note: `&apos;` is XML only
116
+ }
117
+
118
+ // This code was written by Tyler Akins and has been placed in the
119
+ // public domain. It would be nice if you left this header intact.
120
+ // Base64 code from Tyler Akins -- http://rumkin.com
121
+
122
+ // schiller: Removed string concatenation in favour of Array.join() optimization,
123
+ // also precalculate the size of the array needed.
124
+
125
+ /**
126
+ * Converts a string to base64.
127
+ * @function module:utilities.encode64
128
+ * @param {string} input
129
+ * @returns {string} Base64 output
130
+ */
131
+ export function encode64 (input) {
132
+ // base64 strings are 4/3 larger than the original string
133
+ input = encodeUTF8(input) // convert non-ASCII characters
134
+ return window.btoa(input) // Use native if available
135
+ }
136
+
137
+ /**
138
+ * Converts a string from base64.
139
+ * @function module:utilities.decode64
140
+ * @param {string} input Base64-encoded input
141
+ * @returns {string} Decoded output
142
+ */
143
+ export function decode64 (input) {
144
+ return decodeUTF8(window.atob(input))
145
+ }
146
+
147
+ /**
148
+ * Compute a hashcode from a given string
149
+ * @param word : the string, we want to compute the hashcode
150
+ * @returns {number}: Hascode of the given string
151
+ */
152
+ export function hashCode (word) {
153
+ let hash = 0
154
+ let chr
155
+ if (word.length === 0) return hash
156
+ for (let i = 0; i < word.length; i++) {
157
+ chr = word.charCodeAt(i)
158
+ hash = ((hash << 5) - hash) + chr
159
+ hash |= 0 // Convert to 32bit integer
160
+ }
161
+ return hash
162
+ }
163
+
164
+ /**
165
+ * @function module:utilities.decodeUTF8
166
+ * @param {string} argString
167
+ * @returns {string}
168
+ */
169
+ export function decodeUTF8 (argString) {
170
+ return decodeURIComponent(escape(argString))
171
+ }
172
+
173
+ // codedread:does not seem to work with webkit-based browsers on OSX // Brettz9: please test again as function upgraded
174
+ /**
175
+ * @function module:utilities.encodeUTF8
176
+ * @param {string} argString
177
+ * @returns {string}
178
+ */
179
+ export const encodeUTF8 = (argString) => {
180
+ return unescape(encodeURIComponent(argString))
181
+ }
182
+
183
+ /**
184
+ * Convert dataURL to object URL.
185
+ * @function module:utilities.dataURLToObjectURL
186
+ * @param {string} dataurl
187
+ * @returns {string} object URL or empty string
188
+ */
189
+ export const dataURLToObjectURL = (dataurl) => {
190
+ if (typeof Uint8Array === 'undefined' || typeof Blob === 'undefined' || typeof URL === 'undefined' || !URL.createObjectURL) {
191
+ return ''
192
+ }
193
+ const arr = dataurl.split(',')
194
+ const mime = arr[0].match(/:(.*?);/)[1]
195
+ const bstr = atob(arr[1])
196
+ /*
197
+ const [prefix, suffix] = dataurl.split(','),
198
+ {groups: {mime}} = prefix.match(/:(?<mime>.*?);/),
199
+ bstr = atob(suffix);
200
+ */
201
+ let n = bstr.length
202
+ const u8arr = new Uint8Array(n)
203
+ while (n--) {
204
+ u8arr[n] = bstr.charCodeAt(n)
205
+ }
206
+ const blob = new Blob([u8arr], { type: mime })
207
+ return URL.createObjectURL(blob)
208
+ }
209
+
210
+ /**
211
+ * Get object URL for a blob object.
212
+ * @function module:utilities.createObjectURL
213
+ * @param {Blob} blob A Blob object or File object
214
+ * @returns {string} object URL or empty string
215
+ */
216
+ export const createObjectURL = (blob) => {
217
+ if (!blob || typeof URL === 'undefined' || !URL.createObjectURL) {
218
+ return ''
219
+ }
220
+ return URL.createObjectURL(blob)
221
+ }
222
+
223
+ /**
224
+ * @property {string} blankPageObjectURL
225
+ */
226
+ export const blankPageObjectURL = (() => {
227
+ if (typeof Blob === 'undefined') {
228
+ return ''
229
+ }
230
+ const blob = new Blob(['<html><head><title>SVG-edit</title></head><body>&nbsp;</body></html>'], { type: 'text/html' })
231
+ return createObjectURL(blob)
232
+ })()
233
+
234
+ /**
235
+ * Converts a string to use XML references (for non-ASCII).
236
+ * @function module:utilities.convertToXMLReferences
237
+ * @param {string} input
238
+ * @returns {string} Decimal numeric character references
239
+ */
240
+ export const convertToXMLReferences = (input) => {
241
+ let output = '';
242
+ [...input].forEach((ch) => {
243
+ const c = ch.charCodeAt()
244
+ output += (c <= 127) ? ch : `&#${c};`
245
+ })
246
+ return output
247
+ }
248
+
249
+ /**
250
+ * Cross-browser compatible method of converting a string to an XML tree.
251
+ * Found this function [here]{@link http://groups.google.com/group/jquery-dev/browse_thread/thread/c6d11387c580a77f}.
252
+ * @function module:utilities.text2xml
253
+ * @param {string} sXML
254
+ * @throws {Error}
255
+ * @returns {XMLDocument}
256
+ */
257
+ export const text2xml = (sXML) => {
258
+ if (sXML.includes('<svg:svg')) {
259
+ sXML = sXML.replace(/<(\/?)svg:/g, '<$1').replace('xmlns:svg', 'xmlns')
260
+ }
261
+
262
+ let out; let dXML
263
+ try {
264
+ dXML = new DOMParser()
265
+ dXML.async = false
266
+ } catch (e) {
267
+ throw new Error('XML Parser could not be instantiated')
268
+ }
269
+ try {
270
+ out = dXML.parseFromString(sXML, 'text/xml')
271
+ } catch (e2) { throw new Error('Error parsing XML string') }
272
+ return out
273
+ }
274
+
275
+ /**
276
+ * @typedef {PlainObject} module:utilities.BBoxObject (like `DOMRect`)
277
+ * @property {Float} x
278
+ * @property {Float} y
279
+ * @property {Float} width
280
+ * @property {Float} height
281
+ */
282
+
283
+ /**
284
+ * Converts a `SVGRect` into an object.
285
+ * @function module:utilities.bboxToObj
286
+ * @param {SVGRect} bbox - a SVGRect
287
+ * @returns {module:utilities.BBoxObject} An object with properties names x, y, width, height.
288
+ */
289
+ export const bboxToObj = ({ x, y, width, height }) => {
290
+ return { x, y, width, height }
291
+ }
292
+
293
+ /**
294
+ * @callback module:utilities.TreeWalker
295
+ * @param {Element} elem - DOM element being traversed
296
+ * @returns {void}
297
+ */
298
+
299
+ /**
300
+ * Walks the tree and executes the callback on each element in a top-down fashion.
301
+ * @function module:utilities.walkTree
302
+ * @param {Element} elem - DOM element to traverse
303
+ * @param {module:utilities.TreeWalker} cbFn - Callback function to run on each element
304
+ * @returns {void}
305
+ */
306
+ export const walkTree = (elem, cbFn) => {
307
+ if (elem?.nodeType === 1) {
308
+ cbFn(elem)
309
+ let i = elem.childNodes.length
310
+ while (i--) {
311
+ walkTree(elem.childNodes.item(i), cbFn)
312
+ }
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Walks the tree and executes the callback on each element in a depth-first fashion.
318
+ * @function module:utilities.walkTreePost
319
+ * @todo Shouldn't this be calling walkTreePost?
320
+ * @param {Element} elem - DOM element to traverse
321
+ * @param {module:utilities.TreeWalker} cbFn - Callback function to run on each element
322
+ * @returns {void}
323
+ */
324
+ export const walkTreePost = (elem, cbFn) => {
325
+ if (elem?.nodeType === 1) {
326
+ let i = elem.childNodes.length
327
+ while (i--) {
328
+ walkTree(elem.childNodes.item(i), cbFn)
329
+ }
330
+ cbFn(elem)
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Extracts the URL from the `url(...)` syntax of some attributes.
336
+ * Three variants:
337
+ * - `<circle fill="url(someFile.svg#foo)" />`
338
+ * - `<circle fill="url('someFile.svg#foo')" />`
339
+ * - `<circle fill='url("someFile.svg#foo")' />`
340
+ * @function module:utilities.getUrlFromAttr
341
+ * @param {string} attrVal The attribute value as a string
342
+ * @returns {string} String with just the URL, like "someFile.svg#foo"
343
+ */
344
+ export const getUrlFromAttr = function (attrVal) {
345
+ if (attrVal) {
346
+ // url('#somegrad')
347
+ if (attrVal.startsWith('url("')) {
348
+ return attrVal.substring(5, attrVal.indexOf('"', 6))
349
+ }
350
+ // url('#somegrad')
351
+ if (attrVal.startsWith("url('")) {
352
+ return attrVal.substring(5, attrVal.indexOf("'", 6))
353
+ }
354
+ if (attrVal.startsWith('url(')) {
355
+ return attrVal.substring(4, attrVal.indexOf(')'))
356
+ }
357
+ }
358
+ return null
359
+ }
360
+
361
+ /**
362
+ * @function module:utilities.getHref
363
+ * @param {Element} elem
364
+ * @returns {string} The given element's `xlink:href` value
365
+ */
366
+ export let getHref = function (elem) {
367
+ return elem.getAttributeNS(NS.XLINK, 'href')
368
+ }
369
+
370
+ /**
371
+ * Sets the given element's `xlink:href` value.
372
+ * @function module:utilities.setHref
373
+ * @param {Element} elem
374
+ * @param {string} val
375
+ * @returns {void}
376
+ */
377
+ export let setHref = function (elem, val) {
378
+ elem.setAttributeNS(NS.XLINK, 'xlink:href', val)
379
+ }
380
+
381
+ /**
382
+ * @function module:utilities.findDefs
383
+ * @returns {SVGDefsElement} The document's `<defs>` element, creating it first if necessary
384
+ */
385
+ export const findDefs = function () {
386
+ const svgElement = svgCanvas.getSvgContent()
387
+ let defs = svgElement.getElementsByTagNameNS(NS.SVG, 'defs')
388
+ if (defs.length > 0) {
389
+ defs = defs[0]
390
+ } else {
391
+ defs = svgElement.ownerDocument.createElementNS(NS.SVG, 'defs')
392
+ if (svgElement.firstChild) {
393
+ // first child is a comment, so call nextSibling
394
+ svgElement.insertBefore(defs, svgElement.firstChild.nextSibling)
395
+ // svgElement.firstChild.nextSibling.before(defs); // Not safe
396
+ } else {
397
+ svgElement.append(defs)
398
+ }
399
+ }
400
+ return defs
401
+ }
402
+
403
+ // TODO(codedread): Consider moving the next to functions to bbox.js
404
+
405
+ /**
406
+ * Get correct BBox for a path in Webkit.
407
+ * Converted from code found [here]{@link http://blog.hackers-cafe.net/2009/06/how-to-calculate-bezier-curves-bounding.html}.
408
+ * @function module:utilities.getPathBBox
409
+ * @param {SVGPathElement} path - The path DOM element to get the BBox for
410
+ * @returns {module:utilities.BBoxObject} A BBox-like object
411
+ */
412
+ export const getPathBBox = function (path) {
413
+ const seglist = path.pathSegList
414
+ const tot = seglist.numberOfItems
415
+
416
+ const bounds = [[], []]
417
+ const start = seglist.getItem(0)
418
+ let P0 = [start.x, start.y]
419
+
420
+ const getCalc = function (j, P1, P2, P3) {
421
+ return function (t) {
422
+ return 1 - t ** 3 * P0[j] +
423
+ 3 * 1 - t ** 2 * t * P1[j] +
424
+ 3 * (1 - t) * t ** 2 * P2[j] +
425
+ t ** 3 * P3[j]
426
+ }
427
+ }
428
+
429
+ for (let i = 0; i < tot; i++) {
430
+ const seg = seglist.getItem(i)
431
+
432
+ if (seg.x === undefined) { continue }
433
+
434
+ // Add actual points to limits
435
+ bounds[0].push(P0[0])
436
+ bounds[1].push(P0[1])
437
+
438
+ if (seg.x1) {
439
+ const P1 = [seg.x1, seg.y1]
440
+ const P2 = [seg.x2, seg.y2]
441
+ const P3 = [seg.x, seg.y]
442
+
443
+ for (let j = 0; j < 2; j++) {
444
+ const calc = getCalc(j, P1, P2, P3)
445
+
446
+ const b = 6 * P0[j] - 12 * P1[j] + 6 * P2[j]
447
+ const a = -3 * P0[j] + 9 * P1[j] - 9 * P2[j] + 3 * P3[j]
448
+ const c = 3 * P1[j] - 3 * P0[j]
449
+
450
+ if (a === 0) {
451
+ if (b === 0) { continue }
452
+ const t = -c / b
453
+ if (t > 0 && t < 1) {
454
+ bounds[j].push(calc(t))
455
+ }
456
+ continue
457
+ }
458
+ const b2ac = b ** 2 - 4 * c * a
459
+ if (b2ac < 0) { continue }
460
+ const t1 = (-b + Math.sqrt(b2ac)) / (2 * a)
461
+ if (t1 > 0 && t1 < 1) { bounds[j].push(calc(t1)) }
462
+ const t2 = (-b - Math.sqrt(b2ac)) / (2 * a)
463
+ if (t2 > 0 && t2 < 1) { bounds[j].push(calc(t2)) }
464
+ }
465
+ P0 = P3
466
+ } else {
467
+ bounds[0].push(seg.x)
468
+ bounds[1].push(seg.y)
469
+ }
470
+ }
471
+
472
+ const x = Math.min.apply(null, bounds[0])
473
+ const w = Math.max.apply(null, bounds[0]) - x
474
+ const y = Math.min.apply(null, bounds[1])
475
+ const h = Math.max.apply(null, bounds[1]) - y
476
+ return {
477
+ x,
478
+ y,
479
+ width: w,
480
+ height: h
481
+ }
482
+ }
483
+
484
+ /**
485
+ * Get the given/selected element's bounding box object, convert it to be more
486
+ * usable when necessary.
487
+ * @function module:utilities.getBBox
488
+ * @param {Element} elem - Optional DOM element to get the BBox for
489
+ * @returns {module:utilities.BBoxObject} Bounding box object
490
+ */
491
+ export const getBBox = function (elem) {
492
+ const selected = elem || svgCanvas.getSelectedElements()[0]
493
+ if (elem.nodeType !== 1) { return null }
494
+ const elname = selected.nodeName
495
+
496
+ let ret = null
497
+ switch (elname) {
498
+ case 'text':
499
+ if (selected.textContent === '') {
500
+ selected.textContent = 'a' // Some character needed for the selector to use.
501
+ ret = selected.getBBox()
502
+ selected.textContent = ''
503
+ } else if (selected.getBBox) {
504
+ ret = selected.getBBox()
505
+ }
506
+ break
507
+ case 'path':
508
+ case 'g':
509
+ case 'a':
510
+ if (selected.getBBox) {
511
+ ret = selected.getBBox()
512
+ }
513
+ break
514
+ default:
515
+
516
+ if (elname === 'use') {
517
+ ret = selected.getBBox() // , true);
518
+ } else if (visElemsArr.includes(elname)) {
519
+ if (selected) {
520
+ try {
521
+ ret = selected.getBBox()
522
+ } catch (err) {
523
+ // tspan (and textPath apparently) have no `getBBox` in Firefox: https://bugzilla.mozilla.org/show_bug.cgi?id=937268
524
+ // Re: Chrome returning bbox for containing text element, see: https://bugs.chromium.org/p/chromium/issues/detail?id=349835
525
+ const extent = selected.getExtentOfChar(0) // pos+dimensions of the first glyph
526
+ const width = selected.getComputedTextLength() // width of the tspan
527
+ ret = {
528
+ x: extent.x,
529
+ y: extent.y,
530
+ width,
531
+ height: extent.height
532
+ }
533
+ }
534
+ } else {
535
+ // Check if element is child of a foreignObject
536
+ const fo = getClosest(selected.parentNode, 'foreignObject')
537
+ if (fo.length && fo[0].getBBox) {
538
+ ret = fo[0].getBBox()
539
+ }
540
+ }
541
+ }
542
+ }
543
+ if (ret) {
544
+ ret = bboxToObj(ret)
545
+ }
546
+
547
+ // get the bounding box from the DOM (which is in that element's coordinate system)
548
+ return ret
549
+ }
550
+
551
+ /**
552
+ * @typedef {GenericArray} module:utilities.PathSegmentArray
553
+ * @property {Integer} length 2
554
+ * @property {"M"|"L"|"C"|"Z"} 0
555
+ * @property {Float[]} 1
556
+ */
557
+
558
+ /**
559
+ * Create a path 'd' attribute from path segments.
560
+ * Each segment is an array of the form: `[singleChar, [x,y, x,y, ...]]`
561
+ * @function module:utilities.getPathDFromSegments
562
+ * @param {module:utilities.PathSegmentArray[]} pathSegments - An array of path segments to be converted
563
+ * @returns {string} The converted path d attribute.
564
+ */
565
+ export const getPathDFromSegments = function (pathSegments) {
566
+ let d = ''
567
+
568
+ pathSegments.forEach(function ([singleChar, pts], _j) {
569
+ d += singleChar
570
+ for (let i = 0; i < pts.length; i += 2) {
571
+ d += (pts[i] + ',' + pts[i + 1]) + ' '
572
+ }
573
+ })
574
+
575
+ return d
576
+ }
577
+
578
+ /**
579
+ * Make a path 'd' attribute from a simple SVG element shape.
580
+ * @function module:utilities.getPathDFromElement
581
+ * @param {Element} elem - The element to be converted
582
+ * @returns {string} The path d attribute or `undefined` if the element type is unknown.
583
+ */
584
+ export const getPathDFromElement = function (elem) {
585
+ // Possibly the cubed root of 6, but 1.81 works best
586
+ let num = 1.81
587
+ let d; let rx; let ry
588
+ switch (elem.tagName) {
589
+ case 'ellipse':
590
+ case 'circle': {
591
+ rx = Number(elem.getAttribute('rx'))
592
+ ry = Number(elem.getAttribute('ry'))
593
+ const cx = Number(elem.getAttribute('cx'))
594
+ const cy = Number(elem.getAttribute('cy'))
595
+ if (elem.tagName === 'circle' && elem.hasAttribute('r')) {
596
+ ry = Number(elem.getAttribute('r'))
597
+ rx = ry
598
+ }
599
+ d = getPathDFromSegments([
600
+ ['M', [(cx - rx), (cy)]],
601
+ ['C', [(cx - rx), (cy - ry / num), (cx - rx / num), (cy - ry), (cx), (cy - ry)]],
602
+ ['C', [(cx + rx / num), (cy - ry), (cx + rx), (cy - ry / num), (cx + rx), (cy)]],
603
+ ['C', [(cx + rx), (cy + ry / num), (cx + rx / num), (cy + ry), (cx), (cy + ry)]],
604
+ ['C', [(cx - rx / num), (cy + ry), (cx - rx), (cy + ry / num), (cx - rx), (cy)]],
605
+ ['Z', []]
606
+ ])
607
+ break
608
+ } case 'path':
609
+ d = elem.getAttribute('d')
610
+ break
611
+ case 'line': {
612
+ const x1 = elem.getAttribute('x1')
613
+ const y1 = elem.getAttribute('y1')
614
+ const x2 = elem.getAttribute('x2')
615
+ const y2 = elem.getAttribute('y2')
616
+ d = 'M' + x1 + ',' + y1 + 'L' + x2 + ',' + y2
617
+ }
618
+ break
619
+ case 'polyline':
620
+ d = 'M' + elem.getAttribute('points')
621
+ break
622
+ case 'polygon':
623
+ d = 'M' + elem.getAttribute('points') + ' Z'
624
+ break
625
+ case 'rect': {
626
+ rx = Number(elem.getAttribute('rx'))
627
+ ry = Number(elem.getAttribute('ry'))
628
+ const b = elem.getBBox()
629
+ const { x, y } = b
630
+ const w = b.width
631
+ const h = b.height
632
+ num = 4 - num // Why? Because!
633
+
634
+ d = (!rx && !ry)
635
+ // Regular rect
636
+ ? getPathDFromSegments([
637
+ ['M', [x, y]],
638
+ ['L', [x + w, y]],
639
+ ['L', [x + w, y + h]],
640
+ ['L', [x, y + h]],
641
+ ['L', [x, y]],
642
+ ['Z', []]
643
+ ])
644
+ : getPathDFromSegments([
645
+ ['M', [x, y + ry]],
646
+ ['C', [x, y + ry / num, x + rx / num, y, x + rx, y]],
647
+ ['L', [x + w - rx, y]],
648
+ ['C', [x + w - rx / num, y, x + w, y + ry / num, x + w, y + ry]],
649
+ ['L', [x + w, y + h - ry]],
650
+ ['C', [x + w, y + h - ry / num, x + w - rx / num, y + h, x + w - rx, y + h]],
651
+ ['L', [x + rx, y + h]],
652
+ ['C', [x + rx / num, y + h, x, y + h - ry / num, x, y + h - ry]],
653
+ ['L', [x, y + ry]],
654
+ ['Z', []]
655
+ ])
656
+ break
657
+ } default:
658
+ break
659
+ }
660
+
661
+ return d
662
+ }
663
+
664
+ /**
665
+ * Get a set of attributes from an element that is useful for convertToPath.
666
+ * @function module:utilities.getExtraAttributesForConvertToPath
667
+ * @param {Element} elem - The element to be probed
668
+ * @returns {PlainObject<"marker-start"|"marker-end"|"marker-mid"|"filter"|"clip-path", string>} An object with attributes.
669
+ */
670
+ export const getExtraAttributesForConvertToPath = function (elem) {
671
+ const attrs = {};
672
+ // TODO: make this list global so that we can properly maintain it
673
+ // TODO: what about @transform, @clip-rule, @fill-rule, etc?
674
+ ['marker-start', 'marker-end', 'marker-mid', 'filter', 'clip-path'].forEach(function (item) {
675
+ const a = elem.getAttribute(item)
676
+ if (a) {
677
+ attrs[item] = a
678
+ }
679
+ })
680
+ return attrs
681
+ }
682
+
683
+ /**
684
+ * Get the BBox of an element-as-path.
685
+ * @function module:utilities.getBBoxOfElementAsPath
686
+ * @param {Element} elem - The DOM element to be probed
687
+ * @param {module:utilities.EditorContext#addSVGElementsFromJson} addSVGElementsFromJson - Function to add the path element to the current layer. See canvas.addSVGElementsFromJson
688
+ * @param {module:path.pathActions} pathActions - If a transform exists, `pathActions.resetOrientation()` is used. See: canvas.pathActions.
689
+ * @returns {DOMRect|false} The resulting path's bounding box object.
690
+ */
691
+ export const getBBoxOfElementAsPath = function (elem, addSVGElementsFromJson, pathActions) {
692
+ const path = addSVGElementsFromJson({
693
+ element: 'path',
694
+ attr: getExtraAttributesForConvertToPath(elem)
695
+ })
696
+
697
+ const eltrans = elem.getAttribute('transform')
698
+ if (eltrans) {
699
+ path.setAttribute('transform', eltrans)
700
+ }
701
+
702
+ const { parentNode } = elem
703
+ if (elem.nextSibling) {
704
+ elem.before(path)
705
+ } else {
706
+ parentNode.append(path)
707
+ }
708
+
709
+ const d = getPathDFromElement(elem)
710
+ if (d) {
711
+ path.setAttribute('d', d)
712
+ } else {
713
+ path.remove()
714
+ }
715
+
716
+ // Get the correct BBox of the new path, then discard it
717
+ pathActions.resetOrientation(path)
718
+ let bb = false
719
+ try {
720
+ bb = path.getBBox()
721
+ } catch (e) {
722
+ // Firefox fails
723
+ }
724
+ path.remove()
725
+ return bb
726
+ }
727
+
728
+ /**
729
+ * Convert selected element to a path.
730
+ * @function module:utilities.convertToPath
731
+ * @param {Element} elem - The DOM element to be converted
732
+ * @param {module:utilities.SVGElementJSON} attrs - Apply attributes to new path. see canvas.convertToPath
733
+ * @param {module:utilities.EditorContext#addSVGElementsFromJson} addSVGElementsFromJson - Function to add the path element to the current layer. See canvas.addSVGElementsFromJson
734
+ * @param {module:path.pathActions} pathActions - If a transform exists, pathActions.resetOrientation() is used. See: canvas.pathActions.
735
+ * @param {module:draw.DrawCanvasInit#clearSelection|module:path.EditorContext#clearSelection} clearSelection - see [canvas.clearSelection]{@link module:svgcanvas.SvgCanvas#clearSelection}
736
+ * @param {module:path.EditorContext#addToSelection} addToSelection - see [canvas.addToSelection]{@link module:svgcanvas.SvgCanvas#addToSelection}
737
+ * @param {module:history} hstry - see history module
738
+ * @param {module:path.EditorContext#addCommandToHistory|module:draw.DrawCanvasInit#addCommandToHistory} addCommandToHistory - see [canvas.addCommandToHistory]{@link module:svgcanvas~addCommandToHistory}
739
+ * @returns {SVGPathElement|null} The converted path element or null if the DOM element was not recognized.
740
+ */
741
+ export const convertToPath = (elem, attrs, svgCanvas) => {
742
+ const batchCmd = new svgCanvas.history.BatchCommand('Convert element to Path')
743
+
744
+ // Any attribute on the element not covered by the passed-in attributes
745
+ attrs = mergeDeep(attrs, getExtraAttributesForConvertToPath(elem))
746
+
747
+ const path = svgCanvas.addSVGElementsFromJson({
748
+ element: 'path',
749
+ attr: attrs
750
+ })
751
+
752
+ const eltrans = elem.getAttribute('transform')
753
+ if (eltrans) {
754
+ path.setAttribute('transform', eltrans)
755
+ }
756
+
757
+ const { id } = elem
758
+ const { parentNode } = elem
759
+ if (elem.nextSibling) {
760
+ elem.before(path)
761
+ } else {
762
+ parentNode.append(path)
763
+ }
764
+
765
+ const d = getPathDFromElement(elem)
766
+ if (d) {
767
+ path.setAttribute('d', d)
768
+
769
+ // Replace the current element with the converted one
770
+
771
+ // Reorient if it has a matrix
772
+ if (eltrans) {
773
+ const tlist = path.transform.baseVal
774
+ if (hasMatrixTransform(tlist)) {
775
+ svgCanvas.pathActions.resetOrientation(path)
776
+ }
777
+ }
778
+
779
+ const { nextSibling } = elem
780
+ batchCmd.addSubCommand(new svgCanvas.history.RemoveElementCommand(elem, nextSibling, elem.parentNode))
781
+ svgCanvas.clearSelection()
782
+ elem.remove() // We need to remove this element otherwise the nextSibling of 'path' won't be null and an exception will be thrown after subsequent undo and redos.
783
+
784
+ batchCmd.addSubCommand(new svgCanvas.history.InsertElementCommand(path))
785
+ path.setAttribute('id', id)
786
+ path.removeAttribute('visibility')
787
+ svgCanvas.addToSelection([path], true)
788
+
789
+ svgCanvas.addCommandToHistory(batchCmd)
790
+
791
+ return path
792
+ }
793
+ // the elem.tagName was not recognized, so no "d" attribute. Remove it, so we've haven't changed anything.
794
+ path.remove()
795
+ return null
796
+ }
797
+
798
+ /**
799
+ * Can the bbox be optimized over the native getBBox? The optimized bbox is the same as the native getBBox when
800
+ * the rotation angle is a multiple of 90 degrees and there are no complex transforms.
801
+ * Getting an optimized bbox can be dramatically slower, so we want to make sure it's worth it.
802
+ *
803
+ * The best example for this is a circle rotate 45 degrees. The circle doesn't get wider or taller when rotated
804
+ * about it's center.
805
+ *
806
+ * The standard, unoptimized technique gets the native bbox of the circle, rotates the box 45 degrees, uses
807
+ * that width and height, and applies any transforms to get the final bbox. This means the calculated bbox
808
+ * is much wider than the original circle. If the angle had been 0, 90, 180, etc. both techniques render the
809
+ * same bbox.
810
+ *
811
+ * The optimization is not needed if the rotation is a multiple 90 degrees. The default technique is to call
812
+ * getBBox then apply the angle and any transforms.
813
+ *
814
+ * @param {Float} angle - The rotation angle in degrees
815
+ * @param {boolean} hasAMatrixTransform - True if there is a matrix transform
816
+ * @returns {boolean} True if the bbox can be optimized.
817
+ */
818
+ function bBoxCanBeOptimizedOverNativeGetBBox (angle, hasAMatrixTransform) {
819
+ const angleModulo90 = angle % 90
820
+ const closeTo90 = angleModulo90 < -89.99 || angleModulo90 > 89.99
821
+ const closeTo0 = angleModulo90 > -0.001 && angleModulo90 < 0.001
822
+ return hasAMatrixTransform || !(closeTo0 || closeTo90)
823
+ }
824
+
825
+ /**
826
+ * Get bounding box that includes any transforms.
827
+ * @function module:utilities.getBBoxWithTransform
828
+ * @param {Element} elem - The DOM element to be converted
829
+ * @param {module:utilities.EditorContext#addSVGElementsFromJson} addSVGElementsFromJson - Function to add the path element to the current layer. See canvas.addSVGElementsFromJson
830
+ * @param {module:path.pathActions} pathActions - If a transform exists, pathActions.resetOrientation() is used. See: canvas.pathActions.
831
+ * @returns {module:utilities.BBoxObject|module:math.TransformedBox|DOMRect} A single bounding box object
832
+ */
833
+ export const getBBoxWithTransform = function (elem, addSVGElementsFromJson, pathActions) {
834
+ // TODO: Fix issue with rotated groups. Currently they work
835
+ // fine in FF, but not in other browsers (same problem mentioned
836
+ // in Issue 339 comment #2).
837
+
838
+ let bb = getBBox(elem)
839
+
840
+ if (!bb) {
841
+ return null
842
+ }
843
+
844
+ const tlist = elem.transform.baseVal
845
+ const angle = getRotationAngleFromTransformList(tlist)
846
+ const hasMatrixXForm = hasMatrixTransform(tlist)
847
+
848
+ if (angle || hasMatrixXForm) {
849
+ let goodBb = false
850
+ if (bBoxCanBeOptimizedOverNativeGetBBox(angle, hasMatrixXForm)) {
851
+ // Get the BBox from the raw path for these elements
852
+ // TODO: why ellipse and not circle
853
+ const elemNames = ['ellipse', 'path', 'line', 'polyline', 'polygon']
854
+ if (elemNames.includes(elem.tagName)) {
855
+ goodBb = getBBoxOfElementAsPath(elem, addSVGElementsFromJson, pathActions)
856
+ bb = goodBb
857
+ } else if (elem.tagName === 'rect') {
858
+ // Look for radius
859
+ const rx = Number(elem.getAttribute('rx'))
860
+ const ry = Number(elem.getAttribute('ry'))
861
+ if (rx || ry) {
862
+ goodBb = getBBoxOfElementAsPath(elem, addSVGElementsFromJson, pathActions)
863
+ bb = goodBb
864
+ }
865
+ }
866
+ }
867
+
868
+ if (!goodBb) {
869
+ const { matrix } = transformListToTransform(tlist)
870
+ bb = transformBox(bb.x, bb.y, bb.width, bb.height, matrix).aabox
871
+ }
872
+ }
873
+ return bb
874
+ }
875
+
876
+ /**
877
+ * @param {Element} elem
878
+ * @returns {Float}
879
+ * @todo This is problematic with large stroke-width and, for example, a single
880
+ * horizontal line. The calculated BBox extends way beyond left and right sides.
881
+ */
882
+ const getStrokeOffsetForBBox = (elem) => {
883
+ const sw = elem.getAttribute('stroke-width')
884
+ return (!isNaN(sw) && elem.getAttribute('stroke') !== 'none') ? sw / 2 : 0
885
+ }
886
+
887
+ /**
888
+ * @typedef {PlainObject} BBox
889
+ * @property {Integer} x The x value
890
+ * @property {Integer} y The y value
891
+ * @property {Float} width
892
+ * @property {Float} height
893
+ */
894
+
895
+ /**
896
+ * Get the bounding box for one or more stroked and/or transformed elements.
897
+ * @function module:utilities.getStrokedBBox
898
+ * @param {Element[]} elems - Array with DOM elements to check
899
+ * @param {module:utilities.EditorContext#addSVGElementsFromJson} addSVGElementsFromJson - Function to add the path element to the current layer. See canvas.addSVGElementsFromJson
900
+ * @param {module:path.pathActions} pathActions - If a transform exists, pathActions.resetOrientation() is used. See: canvas.pathActions.
901
+ * @returns {module:utilities.BBoxObject|module:math.TransformedBox|DOMRect} A single bounding box object
902
+ */
903
+ export const getStrokedBBox = (elems, addSVGElementsFromJson, pathActions) => {
904
+ if (!elems || !elems.length) { return false }
905
+
906
+ let fullBb
907
+ elems.forEach((elem) => {
908
+ if (fullBb) { return }
909
+ if (!elem.parentNode) { return }
910
+ fullBb = getBBoxWithTransform(elem, addSVGElementsFromJson, pathActions)
911
+ })
912
+
913
+ // This shouldn't ever happen...
914
+ if (!fullBb) { return null }
915
+
916
+ // fullBb doesn't include the stoke, so this does no good!
917
+ // if (elems.length == 1) return fullBb;
918
+
919
+ let maxX = fullBb.x + fullBb.width
920
+ let maxY = fullBb.y + fullBb.height
921
+ let minX = fullBb.x
922
+ let minY = fullBb.y
923
+
924
+ // If only one elem, don't call the potentially slow getBBoxWithTransform method again.
925
+ if (elems.length === 1) {
926
+ const offset = getStrokeOffsetForBBox(elems[0])
927
+ minX -= offset
928
+ minY -= offset
929
+ maxX += offset
930
+ maxY += offset
931
+ } else {
932
+ elems.forEach((elem) => {
933
+ const curBb = getBBoxWithTransform(elem, addSVGElementsFromJson, pathActions)
934
+ if (curBb) {
935
+ const offset = getStrokeOffsetForBBox(elem)
936
+ minX = Math.min(minX, curBb.x - offset)
937
+ minY = Math.min(minY, curBb.y - offset)
938
+ // TODO: The old code had this test for max, but not min. I suspect this test should be for both min and max
939
+ if (elem.nodeType === 1) {
940
+ maxX = Math.max(maxX, curBb.x + curBb.width + offset)
941
+ maxY = Math.max(maxY, curBb.y + curBb.height + offset)
942
+ }
943
+ }
944
+ })
945
+ }
946
+
947
+ fullBb.x = minX
948
+ fullBb.y = minY
949
+ fullBb.width = maxX - minX
950
+ fullBb.height = maxY - minY
951
+ return fullBb
952
+ }
953
+
954
+ /**
955
+ * Get all elements that have a BBox (excludes `<defs>`, `<title>`, etc).
956
+ * Note that 0-opacity, off-screen etc elements are still considered "visible"
957
+ * for this function.
958
+ * @function module:utilities.getVisibleElements
959
+ * @param {Element} parentElement - The parent DOM element to search within
960
+ * @returns {Element[]} All "visible" elements.
961
+ */
962
+ export const getVisibleElements = (parentElement) => {
963
+ if (!parentElement) {
964
+ const svgContent = svgCanvas.getSvgContent()
965
+ parentElement = svgContent.children[0] // Prevent layers from being included
966
+ }
967
+
968
+ const contentElems = []
969
+ const children = parentElement.children
970
+ // eslint-disable-next-line array-callback-return
971
+ Array.from(children, (elem) => {
972
+ if (elem.getBBox) {
973
+ contentElems.push(elem)
974
+ }
975
+ })
976
+ return contentElems.reverse()
977
+ }
978
+
979
+ /**
980
+ * Get the bounding box for one or more stroked and/or transformed elements.
981
+ * @function module:utilities.getStrokedBBoxDefaultVisible
982
+ * @param {Element[]} elems - Array with DOM elements to check
983
+ * @returns {module:utilities.BBoxObject} A single bounding box object
984
+ */
985
+ export const getStrokedBBoxDefaultVisible = (elems) => {
986
+ if (!elems) { elems = getVisibleElements() }
987
+ return getStrokedBBox(
988
+ elems,
989
+ svgCanvas.addSVGElementsFromJson,
990
+ svgCanvas.pathActions
991
+ )
992
+ }
993
+
994
+ /**
995
+ * Get the rotation angle of the given transform list.
996
+ * @function module:utilities.getRotationAngleFromTransformList
997
+ * @param {SVGTransformList} tlist - List of transforms
998
+ * @param {boolean} toRad - When true returns the value in radians rather than degrees
999
+ * @returns {Float} The angle in degrees or radians
1000
+ */
1001
+ export const getRotationAngleFromTransformList = (tlist, toRad) => {
1002
+ if (!tlist) { return 0 } // <svg> element have no tlist
1003
+ for (let i = 0; i < tlist.numberOfItems; ++i) {
1004
+ const xform = tlist.getItem(i)
1005
+ if (xform.type === 4) {
1006
+ return toRad ? xform.angle * Math.PI / 180.0 : xform.angle
1007
+ }
1008
+ }
1009
+ return 0.0
1010
+ }
1011
+
1012
+ /**
1013
+ * Get the rotation angle of the given/selected DOM element.
1014
+ * @function module:utilities.getRotationAngle
1015
+ * @param {Element} [elem] - DOM element to get the angle for. Default to first of selected elements.
1016
+ * @param {boolean} [toRad=false] - When true returns the value in radians rather than degrees
1017
+ * @returns {Float} The angle in degrees or radians
1018
+ */
1019
+ export let getRotationAngle = (elem, toRad) => {
1020
+ const selected = elem || svgCanvas.getSelectedElements()[0]
1021
+ // find the rotation transform (if any) and set it
1022
+ const tlist = selected.transform?.baseVal
1023
+ return getRotationAngleFromTransformList(tlist, toRad)
1024
+ }
1025
+
1026
+ /**
1027
+ * Get the reference element associated with the given attribute value.
1028
+ * @function module:utilities.getRefElem
1029
+ * @param {string} attrVal - The attribute value as a string
1030
+ * @returns {Element} Reference element
1031
+ */
1032
+ export const getRefElem = (attrVal) => {
1033
+ return getElement(getUrlFromAttr(attrVal).substr(1))
1034
+ }
1035
+ /**
1036
+ * Get the reference element associated with the given attribute value.
1037
+ * @function module:utilities.getFeGaussianBlur
1038
+ * @param {any} Element
1039
+ * @returns {any} Reference element
1040
+ */
1041
+ export const getFeGaussianBlur = (ele) => {
1042
+ if (ele?.firstChild?.tagName === 'feGaussianBlur') {
1043
+ return ele.firstChild
1044
+ } else {
1045
+ const childrens = ele.children
1046
+ // eslint-disable-next-line no-unused-vars
1047
+ for (const [_, value] of Object.entries(childrens)) {
1048
+ if (value.tagName === 'feGaussianBlur') {
1049
+ return value
1050
+ }
1051
+ }
1052
+ }
1053
+ return null
1054
+ }
1055
+
1056
+ /**
1057
+ * Get a DOM element by ID within the SVG root element.
1058
+ * @function module:utilities.getElement
1059
+ * @param {string} id - String with the element's new ID
1060
+ * @returns {?Element}
1061
+ */
1062
+ export const getElement = (id) => {
1063
+ // querySelector lookup
1064
+ return svgroot_.querySelector('#' + id)
1065
+ }
1066
+
1067
+ /**
1068
+ * Assigns multiple attributes to an element.
1069
+ * @function module:utilities.assignAttributes
1070
+ * @param {Element} elem - DOM element to apply new attribute values to
1071
+ * @param {PlainObject<string, string>} attrs - Object with attribute keys/values
1072
+ * @param {Integer} [suspendLength] - Milliseconds to suspend redraw
1073
+ * @param {boolean} [unitCheck=false] - Boolean to indicate the need to use units.setUnitAttr
1074
+ * @returns {void}
1075
+ */
1076
+ export const assignAttributes = (elem, attrs, suspendLength, unitCheck) => {
1077
+ for (const [key, value] of Object.entries(attrs)) {
1078
+ const ns = (key.substr(0, 4) === 'xml:'
1079
+ ? NS.XML
1080
+ : key.substr(0, 6) === 'xlink:' ? NS.XLINK : null)
1081
+ if (value === undefined) {
1082
+ if (ns) {
1083
+ elem.removeAttributeNS(ns, key)
1084
+ } else {
1085
+ elem.removeAttribute(key)
1086
+ }
1087
+ continue
1088
+ }
1089
+ if (ns) {
1090
+ elem.setAttributeNS(ns, key, value)
1091
+ } else if (!unitCheck) {
1092
+ elem.setAttribute(key, value)
1093
+ } else {
1094
+ setUnitAttr(elem, key, value)
1095
+ }
1096
+ }
1097
+ }
1098
+
1099
+ /**
1100
+ * Remove unneeded (default) attributes, making resulting SVG smaller.
1101
+ * @function module:utilities.cleanupElement
1102
+ * @param {Element} element - DOM element to clean up
1103
+ * @returns {void}
1104
+ */
1105
+ export const cleanupElement = (element) => {
1106
+ const defaults = {
1107
+ 'fill-opacity': 1,
1108
+ 'stop-opacity': 1,
1109
+ opacity: 1,
1110
+ stroke: 'none',
1111
+ 'stroke-dasharray': 'none',
1112
+ 'stroke-linejoin': 'miter',
1113
+ 'stroke-linecap': 'butt',
1114
+ 'stroke-opacity': 1,
1115
+ 'stroke-width': 1,
1116
+ rx: 0,
1117
+ ry: 0
1118
+ }
1119
+
1120
+ if (element.nodeName === 'ellipse') {
1121
+ // Ellipse elements require rx and ry attributes
1122
+ delete defaults.rx
1123
+ delete defaults.ry
1124
+ }
1125
+
1126
+ Object.entries(defaults).forEach(([attr, val]) => {
1127
+ if (element.getAttribute(attr) === String(val)) {
1128
+ element.removeAttribute(attr)
1129
+ }
1130
+ })
1131
+ }
1132
+
1133
+ /**
1134
+ * Round value to for snapping.
1135
+ * @function module:utilities.snapToGrid
1136
+ * @param {Float} value
1137
+ * @returns {Integer}
1138
+ */
1139
+ export const snapToGrid = (value) => {
1140
+ const unit = svgCanvas.getBaseUnit()
1141
+ let stepSize = svgCanvas.getSnappingStep()
1142
+ if (unit !== 'px') {
1143
+ stepSize *= getTypeMap()[unit]
1144
+ }
1145
+ value = Math.round(value / stepSize) * stepSize
1146
+ return value
1147
+ }
1148
+
1149
+ /**
1150
+ * Prevents default browser click behaviour on the given element.
1151
+ * @function module:utilities.preventClickDefault
1152
+ * @param {Element} img - The DOM element to prevent the click on
1153
+ * @returns {void}
1154
+ */
1155
+ export const preventClickDefault = (img) => {
1156
+ $click(img, (e) => {
1157
+ e.preventDefault()
1158
+ })
1159
+ }
1160
+
1161
+ /**
1162
+ * @callback module:utilities.GetNextID
1163
+ * @returns {string} The ID
1164
+ */
1165
+
1166
+ /**
1167
+ * Whether a value is `null` or `undefined`.
1168
+ * @param {any} val
1169
+ * @returns {boolean}
1170
+ */
1171
+ export const isNullish = (val) => {
1172
+ return val === null || val === undefined
1173
+ }
1174
+
1175
+ /**
1176
+ * Overwrite methods for unit testing.
1177
+ * @function module:utilities.mock
1178
+ * @param {PlainObject} mockMethods
1179
+ * @param {module:utilities.getHref} mockMethods.getHref
1180
+ * @param {module:utilities.setHref} mockMethods.setHref
1181
+ * @param {module:utilities.getRotationAngle} mockMethods.getRotationAngle
1182
+ * @returns {void}
1183
+ */
1184
+ export const mock = ({
1185
+ getHref: getHrefUser, setHref: setHrefUser, getRotationAngle: getRotationAngleUser
1186
+ }) => {
1187
+ getHref = getHrefUser
1188
+ setHref = setHrefUser
1189
+ getRotationAngle = getRotationAngleUser
1190
+ }
1191
+
1192
+ export const stringToHTML = (str) => {
1193
+ const parser = new DOMParser()
1194
+ const doc = parser.parseFromString(str, 'text/html')
1195
+ return doc.body.firstChild
1196
+ }
1197
+
1198
+ export const insertChildAtIndex = (parent, child, index = 0) => {
1199
+ const doc = stringToHTML(child)
1200
+ if (index >= parent.children.length) {
1201
+ parent.appendChild(doc)
1202
+ } else {
1203
+ parent.insertBefore(doc, parent.children[index])
1204
+ }
1205
+ }
1206
+
1207
+ // shortcuts to common DOM functions
1208
+ export const $id = (id) => document.getElementById(id)
1209
+ export const $qq = (sel) => document.querySelector(sel)
1210
+ export const $qa = (sel) => [...document.querySelectorAll(sel)]
1211
+ export const $click = (element, handler) => {
1212
+ element.addEventListener('click', handler)
1213
+ element.addEventListener('touchend', handler)
1214
+ }