@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/select.js ADDED
@@ -0,0 +1,543 @@
1
+ /**
2
+ * DOM element selection box tools.
3
+ * @module select
4
+ * @license MIT
5
+ *
6
+ * @copyright 2010 Alexis Deveria, 2010 Jeff Schiller
7
+ */
8
+
9
+ import { isWebkit } from '../../src/common/browser.js'
10
+ import { getRotationAngle, getBBox, getStrokedBBox } from './utilities.js'
11
+ import { transformListToTransform, transformBox, transformPoint } from './math.js'
12
+
13
+ let svgCanvas
14
+ let selectorManager_ // A Singleton
15
+ // change radius if touch screen
16
+ const gripRadius = window.ontouchstart ? 10 : 4
17
+
18
+ /**
19
+ * Private class for DOM element selection boxes.
20
+ */
21
+ export class Selector {
22
+ /**
23
+ * @param {Integer} id - Internally identify the selector
24
+ * @param {Element} elem - DOM element associated with this selector
25
+ * @param {module:utilities.BBoxObject} [bbox] - Optional bbox to use for initialization (prevents duplicate `getBBox` call).
26
+ */
27
+ constructor (id, elem, bbox) {
28
+ // this is the selector's unique number
29
+ this.id = id
30
+
31
+ // this holds a reference to the element for which this selector is being used
32
+ this.selectedElement = elem
33
+
34
+ // this is a flag used internally to track whether the selector is being used or not
35
+ this.locked = true
36
+
37
+ // this holds a reference to the <g> element that holds all visual elements of the selector
38
+ this.selectorGroup = svgCanvas.createSVGElement({
39
+ element: 'g',
40
+ attr: { id: ('selectorGroup' + this.id) }
41
+ })
42
+
43
+ // this holds a reference to the path rect
44
+ this.selectorRect = svgCanvas.createSVGElement({
45
+ element: 'path',
46
+ attr: {
47
+ id: ('selectedBox' + this.id),
48
+ fill: 'none',
49
+ stroke: '#22C',
50
+ 'stroke-width': '1',
51
+ 'stroke-dasharray': '5,5',
52
+ // need to specify this so that the rect is not selectable
53
+ style: 'pointer-events:none'
54
+ }
55
+ })
56
+ this.selectorGroup.append(this.selectorRect)
57
+
58
+ // this holds a reference to the grip coordinates for this selector
59
+ this.gripCoords = {
60
+ nw: null,
61
+ n: null,
62
+ ne: null,
63
+ e: null,
64
+ se: null,
65
+ s: null,
66
+ sw: null,
67
+ w: null
68
+ }
69
+
70
+ this.reset(this.selectedElement, bbox)
71
+ }
72
+
73
+ /**
74
+ * Used to reset the id and element that the selector is attached to.
75
+ * @param {Element} e - DOM element associated with this selector
76
+ * @param {module:utilities.BBoxObject} bbox - Optional bbox to use for reset (prevents duplicate getBBox call).
77
+ * @returns {void}
78
+ */
79
+ reset (e, bbox) {
80
+ this.locked = true
81
+ this.selectedElement = e
82
+ this.resize(bbox)
83
+ this.selectorGroup.setAttribute('display', 'inline')
84
+ }
85
+
86
+ /**
87
+ * Show the resize grips of this selector.
88
+ * @param {boolean} show - Indicates whether grips should be shown or not
89
+ * @returns {void}
90
+ */
91
+ showGrips (show) {
92
+ const bShow = show ? 'inline' : 'none'
93
+ selectorManager_.selectorGripsGroup.setAttribute('display', bShow)
94
+ const elem = this.selectedElement
95
+ this.hasGrips = show
96
+ if (elem && show) {
97
+ this.selectorGroup.append(selectorManager_.selectorGripsGroup)
98
+ Selector.updateGripCursors(getRotationAngle(elem))
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Updates the selector to match the element's size.
104
+ * @param {module:utilities.BBoxObject} [bbox] - BBox to use for resize (prevents duplicate getBBox call).
105
+ * @returns {void}
106
+ */
107
+ resize (bbox) {
108
+ const dataStorage = svgCanvas.getDataStorage()
109
+ const selectedBox = this.selectorRect
110
+ const mgr = selectorManager_
111
+ const selectedGrips = mgr.selectorGrips
112
+ const selected = this.selectedElement
113
+ const zoom = svgCanvas.getZoom()
114
+ let offset = 1 / zoom
115
+ const sw = selected.getAttribute('stroke-width')
116
+ if (selected.getAttribute('stroke') !== 'none' && !isNaN(sw)) {
117
+ offset += (sw / 2)
118
+ }
119
+
120
+ const { tagName } = selected
121
+ if (tagName === 'text') {
122
+ offset += 2 / zoom
123
+ }
124
+
125
+ // loop and transform our bounding box until we reach our first rotation
126
+ const tlist = selected.transform.baseVal
127
+ const m = transformListToTransform(tlist).matrix
128
+
129
+ // This should probably be handled somewhere else, but for now
130
+ // it keeps the selection box correctly positioned when zoomed
131
+ m.e *= zoom
132
+ m.f *= zoom
133
+
134
+ if (!bbox) {
135
+ bbox = getBBox(selected)
136
+ }
137
+ // TODO: getBBox (previous line) already knows to call getStrokedBBox when tagName === 'g'. Remove this?
138
+ // TODO: getBBox doesn't exclude 'gsvg' and calls getStrokedBBox for any 'g'. Should getBBox be updated?
139
+ if (tagName === 'g' && !dataStorage.has(selected, 'gsvg')) {
140
+ // The bbox for a group does not include stroke vals, so we
141
+ // get the bbox based on its children.
142
+ const strokedBbox = getStrokedBBox([selected.childNodes])
143
+ if (strokedBbox) {
144
+ bbox = strokedBbox
145
+ }
146
+ }
147
+
148
+ // apply the transforms
149
+ const l = bbox.x; const t = bbox.y; const w = bbox.width; const h = bbox.height
150
+ // bbox = {x: l, y: t, width: w, height: h}; // Not in use
151
+
152
+ // we need to handle temporary transforms too
153
+ // if skewed, get its transformed box, then find its axis-aligned bbox
154
+
155
+ // *
156
+ offset *= zoom
157
+
158
+ const nbox = transformBox(l * zoom, t * zoom, w * zoom, h * zoom, m)
159
+ const { aabox } = nbox
160
+ let nbax = aabox.x - offset
161
+ let nbay = aabox.y - offset
162
+ let nbaw = aabox.width + (offset * 2)
163
+ let nbah = aabox.height + (offset * 2)
164
+
165
+ // now if the shape is rotated, un-rotate it
166
+ const cx = nbax + nbaw / 2
167
+ const cy = nbay + nbah / 2
168
+
169
+ const angle = getRotationAngle(selected)
170
+ if (angle) {
171
+ const rot = svgCanvas.getSvgRoot().createSVGTransform()
172
+ rot.setRotate(-angle, cx, cy)
173
+ const rotm = rot.matrix
174
+ nbox.tl = transformPoint(nbox.tl.x, nbox.tl.y, rotm)
175
+ nbox.tr = transformPoint(nbox.tr.x, nbox.tr.y, rotm)
176
+ nbox.bl = transformPoint(nbox.bl.x, nbox.bl.y, rotm)
177
+ nbox.br = transformPoint(nbox.br.x, nbox.br.y, rotm)
178
+
179
+ // calculate the axis-aligned bbox
180
+ const { tl } = nbox
181
+ let minx = tl.x
182
+ let miny = tl.y
183
+ let maxx = tl.x
184
+ let maxy = tl.y
185
+
186
+ const { min, max } = Math
187
+
188
+ minx = min(minx, min(nbox.tr.x, min(nbox.bl.x, nbox.br.x))) - offset
189
+ miny = min(miny, min(nbox.tr.y, min(nbox.bl.y, nbox.br.y))) - offset
190
+ maxx = max(maxx, max(nbox.tr.x, max(nbox.bl.x, nbox.br.x))) + offset
191
+ maxy = max(maxy, max(nbox.tr.y, max(nbox.bl.y, nbox.br.y))) + offset
192
+
193
+ nbax = minx
194
+ nbay = miny
195
+ nbaw = (maxx - minx)
196
+ nbah = (maxy - miny)
197
+ }
198
+
199
+ const dstr = 'M' + nbax + ',' + nbay +
200
+ ' L' + (nbax + nbaw) + ',' + nbay +
201
+ ' ' + (nbax + nbaw) + ',' + (nbay + nbah) +
202
+ ' ' + nbax + ',' + (nbay + nbah) + 'z'
203
+
204
+ const xform = angle ? 'rotate(' + [angle, cx, cy].join(',') + ')' : ''
205
+
206
+ // TODO(codedread): Is this needed?
207
+ // if (selected === selectedElements[0]) {
208
+ this.gripCoords = {
209
+ nw: [nbax, nbay],
210
+ ne: [nbax + nbaw, nbay],
211
+ sw: [nbax, nbay + nbah],
212
+ se: [nbax + nbaw, nbay + nbah],
213
+ n: [nbax + (nbaw) / 2, nbay],
214
+ w: [nbax, nbay + (nbah) / 2],
215
+ e: [nbax + nbaw, nbay + (nbah) / 2],
216
+ s: [nbax + (nbaw) / 2, nbay + nbah]
217
+ }
218
+ selectedBox.setAttribute('d', dstr)
219
+ this.selectorGroup.setAttribute('transform', xform)
220
+ Object.entries(this.gripCoords).forEach(([dir, coords]) => {
221
+ selectedGrips[dir].setAttribute('cx', coords[0])
222
+ selectedGrips[dir].setAttribute('cy', coords[1])
223
+ })
224
+
225
+ // we want to go 20 pixels in the negative transformed y direction, ignoring scale
226
+ mgr.rotateGripConnector.setAttribute('x1', nbax + (nbaw) / 2)
227
+ mgr.rotateGripConnector.setAttribute('y1', nbay)
228
+ mgr.rotateGripConnector.setAttribute('x2', nbax + (nbaw) / 2)
229
+ mgr.rotateGripConnector.setAttribute('y2', nbay - (gripRadius * 5))
230
+
231
+ mgr.rotateGrip.setAttribute('cx', nbax + (nbaw) / 2)
232
+ mgr.rotateGrip.setAttribute('cy', nbay - (gripRadius * 5))
233
+ // }
234
+ }
235
+
236
+ // STATIC methods
237
+ /**
238
+ * Updates cursors for corner grips on rotation so arrows point the right way.
239
+ * @param {Float} angle - Current rotation angle in degrees
240
+ * @returns {void}
241
+ */
242
+ static updateGripCursors (angle) {
243
+ const dirArr = Object.keys(selectorManager_.selectorGrips)
244
+ let steps = Math.round(angle / 45)
245
+ if (steps < 0) { steps += 8 }
246
+ while (steps > 0) {
247
+ dirArr.push(dirArr.shift())
248
+ steps--
249
+ }
250
+ Object.values(selectorManager_.selectorGrips).forEach((gripElement, i) => {
251
+ gripElement.setAttribute('style', ('cursor:' + dirArr[i] + '-resize'))
252
+ })
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Manage all selector objects (selection boxes).
258
+ */
259
+ export class SelectorManager {
260
+ /**
261
+ * Sets up properties and calls `initGroup`.
262
+ */
263
+ constructor () {
264
+ // this will hold the <g> element that contains all selector rects/grips
265
+ this.selectorParentGroup = null
266
+
267
+ // this is a special rect that is used for multi-select
268
+ this.rubberBandBox = null
269
+
270
+ // this will hold objects of type Selector (see above)
271
+ this.selectors = []
272
+
273
+ // this holds a map of SVG elements to their Selector object
274
+ this.selectorMap = {}
275
+
276
+ // this holds a reference to the grip elements
277
+ this.selectorGrips = {
278
+ nw: null,
279
+ n: null,
280
+ ne: null,
281
+ e: null,
282
+ se: null,
283
+ s: null,
284
+ sw: null,
285
+ w: null
286
+ }
287
+
288
+ this.selectorGripsGroup = null
289
+ this.rotateGripConnector = null
290
+ this.rotateGrip = null
291
+
292
+ this.initGroup()
293
+ }
294
+
295
+ /**
296
+ * Resets the parent selector group element.
297
+ * @returns {void}
298
+ */
299
+ initGroup () {
300
+ const dataStorage = svgCanvas.getDataStorage()
301
+ // remove old selector parent group if it existed
302
+ if (this.selectorParentGroup?.parentNode) {
303
+ this.selectorParentGroup.remove()
304
+ }
305
+
306
+ // create parent selector group and add it to svgroot
307
+ this.selectorParentGroup = svgCanvas.createSVGElement({
308
+ element: 'g',
309
+ attr: { id: 'selectorParentGroup' }
310
+ })
311
+ this.selectorGripsGroup = svgCanvas.createSVGElement({
312
+ element: 'g',
313
+ attr: { display: 'none' }
314
+ })
315
+ this.selectorParentGroup.append(this.selectorGripsGroup)
316
+ svgCanvas.getSvgRoot().append(this.selectorParentGroup)
317
+
318
+ this.selectorMap = {}
319
+ this.selectors = []
320
+ this.rubberBandBox = null
321
+
322
+ // add the corner grips
323
+ Object.keys(this.selectorGrips).forEach((dir) => {
324
+ const grip = svgCanvas.createSVGElement({
325
+ element: 'circle',
326
+ attr: {
327
+ id: ('selectorGrip_resize_' + dir),
328
+ fill: '#22C',
329
+ r: gripRadius,
330
+ style: ('cursor:' + dir + '-resize'),
331
+ // This expands the mouse-able area of the grips making them
332
+ // easier to grab with the mouse.
333
+ // This works in Opera and WebKit, but does not work in Firefox
334
+ // see https://bugzilla.mozilla.org/show_bug.cgi?id=500174
335
+ 'stroke-width': 2,
336
+ 'pointer-events': 'all'
337
+ }
338
+ })
339
+
340
+ dataStorage.put(grip, 'dir', dir)
341
+ dataStorage.put(grip, 'type', 'resize')
342
+ this.selectorGrips[dir] = grip
343
+ this.selectorGripsGroup.append(grip)
344
+ })
345
+
346
+ // add rotator elems
347
+ this.rotateGripConnector =
348
+ svgCanvas.createSVGElement({
349
+ element: 'line',
350
+ attr: {
351
+ id: ('selectorGrip_rotateconnector'),
352
+ stroke: '#22C',
353
+ 'stroke-width': '1'
354
+ }
355
+ })
356
+ this.selectorGripsGroup.append(this.rotateGripConnector)
357
+
358
+ this.rotateGrip =
359
+ svgCanvas.createSVGElement({
360
+ element: 'circle',
361
+ attr: {
362
+ id: 'selectorGrip_rotate',
363
+ fill: 'lime',
364
+ r: gripRadius,
365
+ stroke: '#22C',
366
+ 'stroke-width': 2,
367
+ style: `cursor:url(${svgCanvas.curConfig.imgPath}/rotate.svg) 12 12, auto;`
368
+ }
369
+ })
370
+ this.selectorGripsGroup.append(this.rotateGrip)
371
+ dataStorage.put(this.rotateGrip, 'type', 'rotate')
372
+
373
+ if (document.getElementById('canvasBackground')) { return }
374
+
375
+ const [width, height] = svgCanvas.curConfig.dimensions
376
+ const canvasbg = svgCanvas.createSVGElement({
377
+ element: 'svg',
378
+ attr: {
379
+ id: 'canvasBackground',
380
+ width,
381
+ height,
382
+ x: 0,
383
+ y: 0,
384
+ overflow: (isWebkit() ? 'none' : 'visible'), // Chrome 7 has a problem with this when zooming out
385
+ style: 'pointer-events:none'
386
+ }
387
+ })
388
+
389
+ const rect = svgCanvas.createSVGElement({
390
+ element: 'rect',
391
+ attr: {
392
+ width: '100%',
393
+ height: '100%',
394
+ x: 0,
395
+ y: 0,
396
+ 'stroke-width': 1,
397
+ stroke: '#000',
398
+ fill: '#FFF',
399
+ style: 'pointer-events:none'
400
+ }
401
+ })
402
+ canvasbg.append(rect)
403
+ svgCanvas.getSvgRoot().insertBefore(canvasbg, svgCanvas.getSvgContent())
404
+ }
405
+
406
+ /**
407
+ *
408
+ * @param {Element} elem - DOM element to get the selector for
409
+ * @param {module:utilities.BBoxObject} [bbox] - Optional bbox to use for reset (prevents duplicate getBBox call).
410
+ * @returns {Selector} The selector based on the given element
411
+ */
412
+ requestSelector (elem, bbox) {
413
+ if (!elem) { return null }
414
+
415
+ const N = this.selectors.length
416
+ // If we've already acquired one for this element, return it.
417
+ if (typeof this.selectorMap[elem.id] === 'object') {
418
+ this.selectorMap[elem.id].locked = true
419
+ return this.selectorMap[elem.id]
420
+ }
421
+ for (let i = 0; i < N; ++i) {
422
+ if (!this.selectors[i]?.locked) {
423
+ this.selectors[i].locked = true
424
+ this.selectors[i].reset(elem, bbox)
425
+ this.selectorMap[elem.id] = this.selectors[i]
426
+ return this.selectors[i]
427
+ }
428
+ }
429
+ // if we reached here, no available selectors were found, we create one
430
+ this.selectors[N] = new Selector(N, elem, bbox)
431
+ this.selectorParentGroup.append(this.selectors[N].selectorGroup)
432
+ this.selectorMap[elem.id] = this.selectors[N]
433
+ return this.selectors[N]
434
+ }
435
+
436
+ /**
437
+ * Removes the selector of the given element (hides selection box).
438
+ *
439
+ * @param {Element} elem - DOM element to remove the selector for
440
+ * @returns {void}
441
+ */
442
+ releaseSelector (elem) {
443
+ if (!elem) { return }
444
+ const N = this.selectors.length
445
+ const sel = this.selectorMap[elem.id]
446
+ if (!sel?.locked) {
447
+ // TODO(codedread): Ensure this exists in this module.
448
+ console.warn('WARNING! selector was released but was already unlocked')
449
+ }
450
+ for (let i = 0; i < N; ++i) {
451
+ if (this.selectors[i] && this.selectors[i] === sel) {
452
+ delete this.selectorMap[elem.id]
453
+ sel.locked = false
454
+ sel.selectedElement = null
455
+ sel.showGrips(false)
456
+
457
+ // remove from DOM and store reference in JS but only if it exists in the DOM
458
+ try {
459
+ sel.selectorGroup.setAttribute('display', 'none')
460
+ } catch (e) { /* empty fn */ }
461
+
462
+ break
463
+ }
464
+ }
465
+ }
466
+
467
+ /**
468
+ * @returns {SVGRectElement} The rubberBandBox DOM element. This is the rectangle drawn by
469
+ * the user for selecting/zooming
470
+ */
471
+ getRubberBandBox () {
472
+ if (!this.rubberBandBox) {
473
+ this.rubberBandBox =
474
+ svgCanvas.createSVGElement({
475
+ element: 'rect',
476
+ attr: {
477
+ id: 'selectorRubberBand',
478
+ fill: '#22C',
479
+ 'fill-opacity': 0.15,
480
+ stroke: '#22C',
481
+ 'stroke-width': 0.5,
482
+ display: 'none',
483
+ style: 'pointer-events:none'
484
+ }
485
+ })
486
+ this.selectorParentGroup.append(this.rubberBandBox)
487
+ }
488
+ return this.rubberBandBox
489
+ }
490
+ }
491
+
492
+ /**
493
+ * An object that creates SVG elements for the canvas.
494
+ *
495
+ * @interface module:select.SVGFactory
496
+ */
497
+ /**
498
+ * @function module:select.SVGFactory#createSVGElement
499
+ * @param {module:utilities.EditorContext#addSVGElementsFromJson} jsonMap
500
+ * @returns {SVGElement}
501
+ */
502
+ /**
503
+ * @function module:select.SVGFactory#svgRoot
504
+ * @returns {SVGSVGElement}
505
+ */
506
+ /**
507
+ * @function module:select.SVGFactory#svgContent
508
+ * @returns {SVGSVGElement}
509
+ */
510
+ /**
511
+ * @function module:select.SVGFactory#getZoom
512
+ * @returns {Float} The current zoom level
513
+ */
514
+
515
+ /**
516
+ * @typedef {GenericArray} module:select.Dimensions
517
+ * @property {Integer} length 2
518
+ * @property {Float} 0 Width
519
+ * @property {Float} 1 Height
520
+ */
521
+ /**
522
+ * @typedef {PlainObject} module:select.Config
523
+ * @property {string} imgPath
524
+ * @property {module:select.Dimensions} dimensions
525
+ */
526
+
527
+ /**
528
+ * Initializes this module.
529
+ * @function module:select.init
530
+ * @param {module:select.Config} config - An object containing configurable parameters (imgPath)
531
+ * @param {module:select.SVGFactory} svgFactory - An object implementing the SVGFactory interface.
532
+ * @returns {void}
533
+ */
534
+ export const init = (canvas) => {
535
+ svgCanvas = canvas
536
+ selectorManager_ = new SelectorManager()
537
+ }
538
+
539
+ /**
540
+ * @function module:select.getSelectorManager
541
+ * @returns {module:select.SelectorManager} The SelectorManager instance.
542
+ */
543
+ export const getSelectorManager = () => selectorManager_