@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/selection.js ADDED
@@ -0,0 +1,482 @@
1
+ /**
2
+ * Tools for selection.
3
+ * @module selection
4
+ * @license MIT
5
+ * @copyright 2011 Jeff Schiller
6
+ */
7
+
8
+ import { NS } from './namespaces.js'
9
+ import {
10
+ getBBox,
11
+ getStrokedBBoxDefaultVisible
12
+ } from './utilities.js'
13
+ import {
14
+ transformPoint,
15
+ transformListToTransform,
16
+ rectsIntersect
17
+ } from './math.js'
18
+ import * as hstry from './history.js'
19
+ import { getClosest } from '../../src/common/util.js'
20
+
21
+ const { BatchCommand } = hstry
22
+ let svgCanvas = null
23
+
24
+ /**
25
+ * @function module:selection.init
26
+ * @param {module:selection.selectionContext} selectionContext
27
+ * @returns {void}
28
+ */
29
+ export const init = (canvas) => {
30
+ svgCanvas = canvas
31
+ svgCanvas.getMouseTarget = getMouseTargetMethod
32
+ svgCanvas.clearSelection = clearSelectionMethod
33
+ svgCanvas.addToSelection = addToSelectionMethod
34
+ svgCanvas.getIntersectionList = getIntersectionListMethod
35
+ svgCanvas.runExtensions = runExtensionsMethod
36
+ svgCanvas.groupSvgElem = groupSvgElem
37
+ svgCanvas.prepareSvg = prepareSvg
38
+ svgCanvas.recalculateAllSelectedDimensions = recalculateAllSelectedDimensions
39
+ svgCanvas.setRotationAngle = setRotationAngle
40
+ }
41
+
42
+ /**
43
+ * Clears the selection. The 'selected' handler is then optionally called.
44
+ * This should really be an intersection applying to all types rather than a union.
45
+ * @name module:selection.SvgCanvas#clearSelection
46
+ * @type {module:draw.DrawCanvasInit#clearSelection|module:path.EditorContext#clearSelection}
47
+ * @fires module:selection.SvgCanvas#event:selected
48
+ */
49
+ const clearSelectionMethod = (noCall) => {
50
+ const selectedElements = svgCanvas.getSelectedElements()
51
+ selectedElements.forEach((elem) => {
52
+ if (!elem) {
53
+ return
54
+ }
55
+
56
+ svgCanvas.selectorManager.releaseSelector(elem)
57
+ })
58
+ svgCanvas?.setEmptySelectedElements()
59
+
60
+ if (!noCall) {
61
+ svgCanvas.call('selected', svgCanvas.getSelectedElements())
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Adds a list of elements to the selection. The 'selected' handler is then called.
67
+ * @name module:selection.SvgCanvas#addToSelection
68
+ * @type {module:path.EditorContext#addToSelection}
69
+ * @fires module:selection.SvgCanvas#event:selected
70
+ */
71
+ const addToSelectionMethod = (elemsToAdd, showGrips) => {
72
+ const selectedElements = svgCanvas.getSelectedElements()
73
+ if (!elemsToAdd.length) {
74
+ return
75
+ }
76
+ // find the first null in our selectedElements array
77
+
78
+ let firstNull = 0
79
+ while (firstNull < selectedElements.length) {
80
+ if (selectedElements[firstNull] === null) {
81
+ break
82
+ }
83
+ ++firstNull
84
+ }
85
+
86
+ // now add each element consecutively
87
+ let i = elemsToAdd.length
88
+ while (i--) {
89
+ let elem = elemsToAdd[i]
90
+ if (!elem || !elem.getBBox) {
91
+ continue
92
+ }
93
+
94
+ if (elem.tagName === 'a' && elem.childNodes.length === 1) {
95
+ // Make "a" element's child be the selected element
96
+ elem = elem.firstChild
97
+ }
98
+
99
+ // if it's not already there, add it
100
+ if (!selectedElements.includes(elem)) {
101
+ selectedElements[firstNull] = elem
102
+
103
+ // only the first selectedBBoxes element is ever used in the codebase these days
104
+ // if (j === 0) selectedBBoxes[0] = utilsGetBBox(elem);
105
+ firstNull++
106
+ const sel = svgCanvas.selectorManager.requestSelector(elem)
107
+
108
+ if (selectedElements.length > 1) {
109
+ sel.showGrips(false)
110
+ }
111
+ }
112
+ }
113
+ if (!selectedElements.length) {
114
+ return
115
+ }
116
+ svgCanvas.call('selected', selectedElements)
117
+
118
+ if (selectedElements.length === 1) {
119
+ svgCanvas.selectorManager
120
+ .requestSelector(selectedElements[0])
121
+ .showGrips(showGrips)
122
+ }
123
+
124
+ // make sure the elements are in the correct order
125
+ // See: https://www.w3.org/TR/DOM-Level-3-Core/core.html#Node3-compareDocumentPosition
126
+
127
+ selectedElements.sort((a, b) => {
128
+ if (a && b && a.compareDocumentPosition) {
129
+ return 3 - (b.compareDocumentPosition(a) & 6)
130
+ }
131
+ if (!a) {
132
+ return 1
133
+ }
134
+ return 0
135
+ })
136
+
137
+ // Make sure first elements are not null
138
+ while (!selectedElements[0]) {
139
+ selectedElements.shift(0)
140
+ }
141
+ }
142
+ /**
143
+ * @name module:svgcanvas.SvgCanvas#getMouseTarget
144
+ * @type {module:path.EditorContext#getMouseTarget}
145
+ */
146
+ const getMouseTargetMethod = (evt) => {
147
+ if (!evt) {
148
+ return null
149
+ }
150
+ let mouseTarget = evt.target
151
+
152
+ // if it was a <use>, Opera and WebKit return the SVGElementInstance
153
+ if (mouseTarget.correspondingUseElement) {
154
+ mouseTarget = mouseTarget.correspondingUseElement
155
+ }
156
+
157
+ // for foreign content, go up until we find the foreignObject
158
+ // WebKit browsers set the mouse target to the svgcanvas div
159
+ if (
160
+ [NS.MATH, NS.HTML].includes(mouseTarget.namespaceURI) &&
161
+ mouseTarget.id !== 'svgcanvas'
162
+ ) {
163
+ while (mouseTarget.nodeName !== 'foreignObject') {
164
+ mouseTarget = mouseTarget.parentNode
165
+ if (!mouseTarget) {
166
+ return svgCanvas.getSvgRoot()
167
+ }
168
+ }
169
+ }
170
+
171
+ // Get the desired mouseTarget with jQuery selector-fu
172
+ // If it's root-like, select the root
173
+ const currentLayer = svgCanvas.getCurrentDrawing().getCurrentLayer()
174
+ const svgRoot = svgCanvas.getSvgRoot()
175
+ const container = svgCanvas.getDOMContainer()
176
+ const content = svgCanvas.getSvgContent()
177
+ if ([svgRoot, container, content, currentLayer].includes(mouseTarget)) {
178
+ return svgCanvas.getSvgRoot()
179
+ }
180
+
181
+ // If it's a selection grip, return the grip parent
182
+ if (getClosest(mouseTarget.parentNode, '#selectorParentGroup')) {
183
+ // While we could instead have just returned mouseTarget,
184
+ // this makes it easier to indentify as being a selector grip
185
+ return svgCanvas.selectorManager.selectorParentGroup
186
+ }
187
+
188
+ while (
189
+ !mouseTarget?.parentNode?.isSameNode(
190
+ svgCanvas.getCurrentGroup() || currentLayer
191
+ )
192
+ ) {
193
+ mouseTarget = mouseTarget.parentNode
194
+ }
195
+
196
+ return mouseTarget
197
+ }
198
+ /**
199
+ * @typedef {module:svgcanvas.ExtensionMouseDownStatus|module:svgcanvas.ExtensionMouseUpStatus|module:svgcanvas.ExtensionIDsUpdatedStatus|module:locale.ExtensionLocaleData[]|void} module:svgcanvas.ExtensionStatus
200
+ * @tutorial ExtensionDocs
201
+ */
202
+ /**
203
+ * @callback module:svgcanvas.ExtensionVarBuilder
204
+ * @param {string} name The name of the extension
205
+ * @returns {module:svgcanvas.SvgCanvas#event:ext_addLangData}
206
+ */
207
+ /**
208
+ * @callback module:svgcanvas.ExtensionNameFilter
209
+ * @param {string} name
210
+ * @returns {boolean}
211
+ */
212
+ /* eslint-disable max-len */
213
+ /**
214
+ * @todo Consider: Should this return an array by default, so extension results aren't overwritten?
215
+ * @todo Would be easier to document if passing in object with key of action and vars as value; could then define an interface which tied both together
216
+ * @function module:svgcanvas.SvgCanvas#runExtensions
217
+ * @param {"mouseDown"|"mouseMove"|"mouseUp"|"zoomChanged"|"IDsUpdated"|"canvasUpdated"|"toolButtonStateUpdate"|"selectedChanged"|"elementTransition"|"elementChanged"|"langReady"|"langChanged"|"addLangData"|"workareaResized"} action
218
+ * @param {module:svgcanvas.SvgCanvas#event:ext_mouseDown|module:svgcanvas.SvgCanvas#event:ext_mouseMove|module:svgcanvas.SvgCanvas#event:ext_mouseUp|module:svgcanvas.SvgCanvas#event:ext_zoomChanged|module:svgcanvas.SvgCanvas#event:ext_IDsUpdated|module:svgcanvas.SvgCanvas#event:ext_canvasUpdated|module:svgcanvas.SvgCanvas#event:ext_toolButtonStateUpdate|module:svgcanvas.SvgCanvas#event:ext_selectedChanged|module:svgcanvas.SvgCanvas#event:ext_elementTransition|module:svgcanvas.SvgCanvas#event:ext_elementChanged|module:svgcanvas.SvgCanvas#event:ext_langReady|module:svgcanvas.SvgCanvas#event:ext_langChanged|module:svgcanvas.SvgCanvas#event:ext_addLangData|module:svgcanvas.SvgCanvas#event:ext_workareaResized|module:svgcanvas.ExtensionVarBuilder} [vars]
219
+ * @param {boolean} [returnArray]
220
+ * @returns {GenericArray<module:svgcanvas.ExtensionStatus>|module:svgcanvas.ExtensionStatus|false} See {@tutorial ExtensionDocs} on the ExtensionStatus.
221
+ */
222
+ /* eslint-enable max-len */
223
+ const runExtensionsMethod = (
224
+ action,
225
+ vars,
226
+ returnArray
227
+ ) => {
228
+ let result = returnArray ? [] : false
229
+ for (const [name, ext] of Object.entries(svgCanvas.getExtensions())) {
230
+ if (typeof vars === 'function') {
231
+ vars = vars(name) // ext, action
232
+ }
233
+ if (ext.eventBased) {
234
+ const event = new CustomEvent('svgedit', {
235
+ detail: {
236
+ action,
237
+ vars
238
+ }
239
+ })
240
+ document.dispatchEvent(event)
241
+ } else if (ext[action]) {
242
+ if (returnArray) {
243
+ result.push(ext[action](vars))
244
+ } else {
245
+ result = ext[action](vars)
246
+ }
247
+ }
248
+ }
249
+ return result
250
+ }
251
+
252
+ /**
253
+ * Get all elements that have a BBox (excludes `<defs>`, `<title>`, etc).
254
+ * Note that 0-opacity, off-screen etc elements are still considered "visible"
255
+ * for this function.
256
+ * @function module:svgcanvas.SvgCanvas#getVisibleElementsAndBBoxes
257
+ * @param {Element} parent - The parent DOM element to search within
258
+ * @returns {ElementAndBBox[]} An array with objects that include:
259
+ */
260
+ const getVisibleElementsAndBBoxes = (parent) => {
261
+ if (!parent) {
262
+ const svgContent = svgCanvas.getSvgContent()
263
+ parent = svgContent.children // Prevent layers from being included
264
+ }
265
+ const contentElems = []
266
+ const elements = parent.children
267
+ Array.from(elements).forEach((elem) => {
268
+ if (elem.getBBox) {
269
+ contentElems.push({ elem, bbox: getStrokedBBoxDefaultVisible([elem]) })
270
+ }
271
+ })
272
+ return contentElems.reverse()
273
+ }
274
+
275
+ /**
276
+ * This method sends back an array or a NodeList full of elements that
277
+ * intersect the multi-select rubber-band-box on the currentLayer only.
278
+ *
279
+ * We brute-force `getIntersectionList` for browsers that do not support it (Firefox).
280
+ *
281
+ * Reference:
282
+ * Firefox does not implement `getIntersectionList()`, see {@link https://bugzilla.mozilla.org/show_bug.cgi?id=501421}.
283
+ * @function module:svgcanvas.SvgCanvas#getIntersectionList
284
+ * @param {SVGRect} rect
285
+ * @returns {Element[]|NodeList} Bbox elements
286
+ */
287
+ const getIntersectionListMethod = (rect) => {
288
+ const zoom = svgCanvas.getZoom()
289
+ if (!svgCanvas.getRubberBox()) {
290
+ return null
291
+ }
292
+
293
+ const parent =
294
+ svgCanvas.getCurrentGroup() ||
295
+ svgCanvas.getCurrentDrawing().getCurrentLayer()
296
+
297
+ let rubberBBox
298
+ if (!rect) {
299
+ rubberBBox = getBBox(svgCanvas.getRubberBox())
300
+ const bb = svgCanvas.getSvgContent().createSVGRect();
301
+
302
+ ['x', 'y', 'width', 'height', 'top', 'right', 'bottom', 'left'].forEach(
303
+ (o) => {
304
+ bb[o] = rubberBBox[o] / zoom
305
+ }
306
+ )
307
+ rubberBBox = bb
308
+ } else {
309
+ rubberBBox = svgCanvas.getSvgContent().createSVGRect()
310
+ rubberBBox.x = rect.x
311
+ rubberBBox.y = rect.y
312
+ rubberBBox.width = rect.width
313
+ rubberBBox.height = rect.height
314
+ }
315
+
316
+ const resultList = []
317
+ if (svgCanvas.getCurBBoxes().length === 0) {
318
+ // Cache all bboxes
319
+ svgCanvas.setCurBBoxes(getVisibleElementsAndBBoxes(parent))
320
+ }
321
+ let i = svgCanvas.getCurBBoxes().length
322
+ while (i--) {
323
+ const curBBoxes = svgCanvas.getCurBBoxes()
324
+ if (!rubberBBox.width) {
325
+ continue
326
+ }
327
+ if (rectsIntersect(rubberBBox, curBBoxes[i].bbox)) {
328
+ resultList.push(curBBoxes[i].elem)
329
+ }
330
+ }
331
+
332
+ // addToSelection expects an array, but it's ok to pass a NodeList
333
+ // because using square-bracket notation is allowed:
334
+ // https://www.w3.org/TR/DOM-Level-2-Core/ecma-script-binding.html
335
+ return resultList
336
+ }
337
+
338
+ /**
339
+ * @typedef {PlainObject} ElementAndBBox
340
+ * @property {Element} elem - The element
341
+ * @property {module:utilities.BBoxObject} bbox - The element's BBox as retrieved from `getStrokedBBoxDefaultVisible`
342
+ */
343
+
344
+ /**
345
+ * Wrap an SVG element into a group element, mark the group as 'gsvg'.
346
+ * @function module:svgcanvas.SvgCanvas#groupSvgElem
347
+ * @param {Element} elem - SVG element to wrap
348
+ * @returns {void}
349
+ */
350
+ const groupSvgElem = (elem) => {
351
+ const dataStorage = svgCanvas.getDataStorage()
352
+ const g = document.createElementNS(NS.SVG, 'g')
353
+ elem.replaceWith(g)
354
+ g.appendChild(elem)
355
+ dataStorage.put(g, 'gsvg', elem)
356
+ g.id = svgCanvas.getNextId()
357
+ }
358
+
359
+ /**
360
+ * Runs the SVG Document through the sanitizer and then updates its paths.
361
+ * @function module:svgcanvas.SvgCanvas#prepareSvg
362
+ * @param {XMLDocument} newDoc - The SVG DOM document
363
+ * @returns {void}
364
+ */
365
+ const prepareSvg = (newDoc) => {
366
+ svgCanvas.sanitizeSvg(newDoc.documentElement)
367
+
368
+ // convert paths into absolute commands
369
+ const paths = [...newDoc.getElementsByTagNameNS(NS.SVG, 'path')]
370
+ paths.forEach((path) => {
371
+ const convertedPath = svgCanvas.pathActions.convertPath(path)
372
+ path.setAttribute('d', convertedPath)
373
+ svgCanvas.pathActions.fixEnd(path)
374
+ })
375
+ }
376
+
377
+ /**
378
+ * Removes any old rotations if present, prepends a new rotation at the
379
+ * transformed center.
380
+ * @function module:svgcanvas.SvgCanvas#setRotationAngle
381
+ * @param {string|Float} val - The new rotation angle in degrees
382
+ * @param {boolean} preventUndo - Indicates whether the action should be undoable or not
383
+ * @fires module:svgcanvas.SvgCanvas#event:changed
384
+ * @returns {void}
385
+ */
386
+ const setRotationAngle = (val, preventUndo) => {
387
+ const selectedElements = svgCanvas.getSelectedElements()
388
+ // ensure val is the proper type
389
+ val = Number.parseFloat(val)
390
+ const elem = selectedElements[0]
391
+ const oldTransform = elem.getAttribute('transform')
392
+ const bbox = getBBox(elem)
393
+ const cx = bbox.x + bbox.width / 2
394
+ const cy = bbox.y + bbox.height / 2
395
+ const tlist = elem.transform.baseVal
396
+
397
+ // only remove the real rotational transform if present (i.e. at index=0)
398
+ if (tlist.numberOfItems > 0) {
399
+ const xform = tlist.getItem(0)
400
+ if (xform.type === 4) {
401
+ tlist.removeItem(0)
402
+ }
403
+ }
404
+ // find Rnc and insert it
405
+ if (val !== 0) {
406
+ const center = transformPoint(
407
+ cx,
408
+ cy,
409
+ transformListToTransform(tlist).matrix
410
+ )
411
+ const Rnc = svgCanvas.getSvgRoot().createSVGTransform()
412
+ Rnc.setRotate(val, center.x, center.y)
413
+ if (tlist.numberOfItems) {
414
+ tlist.insertItemBefore(Rnc, 0)
415
+ } else {
416
+ tlist.appendItem(Rnc)
417
+ }
418
+ } else if (tlist.numberOfItems === 0) {
419
+ elem.removeAttribute('transform')
420
+ }
421
+
422
+ if (!preventUndo) {
423
+ // we need to undo it, then redo it so it can be undo-able! :)
424
+ // TODO: figure out how to make changes to transform list undo-able cross-browser?
425
+ let newTransform = elem.getAttribute('transform')
426
+ // new transform is something like: 'rotate(5 1.39625e-8 -11)'
427
+ // we round the x so it becomes 'rotate(5 0 -11)'
428
+ if (newTransform) {
429
+ const newTransformArray = newTransform.split(' ')
430
+ const round = (num) => Math.round(Number(num) + Number.EPSILON)
431
+ const x = round(newTransformArray[1])
432
+ newTransform = `${newTransformArray[0]} ${x} ${newTransformArray[2]}`
433
+ }
434
+
435
+ if (oldTransform) {
436
+ elem.setAttribute('transform', oldTransform)
437
+ } else {
438
+ elem.removeAttribute('transform')
439
+ }
440
+ svgCanvas.changeSelectedAttribute(
441
+ 'transform',
442
+ newTransform,
443
+ selectedElements
444
+ )
445
+ svgCanvas.call('changed', selectedElements)
446
+ }
447
+ // const pointGripContainer = getElement('pathpointgrip_container');
448
+ // if (elem.nodeName === 'path' && pointGripContainer) {
449
+ // pathActions.setPointContainerTransform(elem.getAttribute('transform'));
450
+ // }
451
+ const selector = svgCanvas.selectorManager.requestSelector(
452
+ selectedElements[0]
453
+ )
454
+ selector.resize()
455
+ svgCanvas.getSelector().updateGripCursors(val)
456
+ }
457
+
458
+ /**
459
+ * Runs `recalculateDimensions` on the selected elements,
460
+ * adding the changes to a single batch command.
461
+ * @function module:svgcanvas.SvgCanvas#recalculateAllSelectedDimensions
462
+ * @fires module:svgcanvas.SvgCanvas#event:changed
463
+ * @returns {void}
464
+ */
465
+ const recalculateAllSelectedDimensions = () => {
466
+ const text =
467
+ svgCanvas.getCurrentResizeMode() === 'none' ? 'position' : 'size'
468
+ const batchCmd = new BatchCommand(text)
469
+ const selectedElements = svgCanvas.getSelectedElements()
470
+
471
+ selectedElements.forEach((elem) => {
472
+ const cmd = svgCanvas.recalculateDimensions(elem)
473
+ if (cmd) {
474
+ batchCmd.addSubCommand(cmd)
475
+ }
476
+ })
477
+
478
+ if (!batchCmd.isEmpty()) {
479
+ svgCanvas.addCommandToHistory(batchCmd)
480
+ svgCanvas.call('changed', selectedElements)
481
+ }
482
+ }