@svgedit/svgcanvas 7.2.7 → 7.4.2

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/core/event.js CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  convertAttrs
13
13
  } from './units.js'
14
14
  import {
15
- transformPoint, hasMatrixTransform, getMatrix, snapToAngle, getTransformList
15
+ transformPoint, hasMatrixTransform, getMatrix, snapToAngle, getTransformList, transformListToTransform
16
16
  } from './math.js'
17
17
  import * as draw from './draw.js'
18
18
  import * as pathModule from './path.js'
@@ -20,7 +20,9 @@ import * as hstry from './history.js'
20
20
  import { findPos } from '../../svgcanvas/common/util.js'
21
21
 
22
22
  const {
23
- InsertElementCommand
23
+ InsertElementCommand,
24
+ BatchCommand,
25
+ ChangeElementCommand
24
26
  } = hstry
25
27
 
26
28
  let svgCanvas = null
@@ -84,9 +86,10 @@ const updateTransformList = (svgRoot, element, dx, dy) => {
84
86
  const xform = svgRoot.createSVGTransform()
85
87
  xform.setTranslate(dx, dy)
86
88
  const tlist = getTransformList(element)
89
+ if (!tlist) { return }
87
90
  if (tlist.numberOfItems) {
88
91
  const firstItem = tlist.getItem(0)
89
- if (firstItem.type === 2) { // SVG_TRANSFORM_TRANSLATE = 2
92
+ if (firstItem.type === SVGTransform.SVG_TRANSFORM_TRANSLATE) {
90
93
  tlist.replaceItem(xform, 0)
91
94
  } else {
92
95
  tlist.insertItemBefore(xform, 0)
@@ -96,6 +99,25 @@ const updateTransformList = (svgRoot, element, dx, dy) => {
96
99
  }
97
100
  }
98
101
 
102
+ // Consolidate an element's current transform list into a single matrix,
103
+ // returning an undo command that restores `oldTransform`. Used for groups
104
+ // (whose transform must stay on the group rather than be flattened into
105
+ // their children) and as a fallback when recalculateDimensions() doesn't
106
+ // recognize the transform-list shape a drag interaction left behind.
107
+ const consolidateTransform = (svgRoot, elem, tlist, oldTransform) => {
108
+ const consolidatedMatrix = transformListToTransform(tlist).matrix
109
+
110
+ while (tlist.numberOfItems > 0) {
111
+ tlist.removeItem(0)
112
+ }
113
+
114
+ const newTransform = svgRoot.createSVGTransform()
115
+ newTransform.setMatrix(consolidatedMatrix)
116
+ tlist.appendItem(newTransform)
117
+
118
+ return new ChangeElementCommand(elem, { transform: oldTransform })
119
+ }
120
+
99
121
  /**
100
122
  *
101
123
  * @param {MouseEvent} evt
@@ -145,6 +167,31 @@ const mouseMoveEvent = (evt) => {
145
167
  let tlist
146
168
  switch (svgCanvas.getCurrentMode()) {
147
169
  case 'select': {
170
+ // Insert dummy transform on first mouse move (drag start), not on click.
171
+ // This avoids creating multiple transforms that trigger unwanted flattening.
172
+ if (!svgCanvas.hasDragStartTransform && selectedElements.length > 0) {
173
+ // Store original transforms BEFORE adding the drag transform (for undo)
174
+ svgCanvas.dragStartTransforms = new Map()
175
+ for (const selectedElement of selectedElements) {
176
+ if (!selectedElement) { continue }
177
+ // Capture the transform attribute before we modify it
178
+ svgCanvas.dragStartTransforms.set(selectedElement, selectedElement.getAttribute('transform') || '')
179
+ const slist = getTransformList(selectedElement)
180
+ if (!slist) { continue }
181
+ // The dummy must already be a translate (not the default identity
182
+ // matrix) so updateTransformList's `firstItem.type === 2` check
183
+ // recognizes and replaces it in place on the very first mousemove,
184
+ // instead of leaving it behind as a stray extra transform item.
185
+ const dummy = svgRoot.createSVGTransform()
186
+ dummy.setTranslate(0, 0)
187
+ if (slist.numberOfItems) {
188
+ slist.insertItemBefore(dummy, 0)
189
+ } else {
190
+ slist.appendItem(dummy)
191
+ }
192
+ }
193
+ svgCanvas.hasDragStartTransform = true
194
+ }
148
195
  // we temporarily use a translate on the element(s) being dragged
149
196
  // this transform is removed upon mousing up and the element is
150
197
  // relocated to the new location
@@ -222,6 +269,7 @@ const mouseMoveEvent = (evt) => {
222
269
  // while the mouse is down, when mouse goes up, we use this to recalculate
223
270
  // the shape's coordinates
224
271
  tlist = getTransformList(selected)
272
+ if (!tlist) { break }
225
273
  const hasMatrix = hasMatrixTransform(tlist)
226
274
  box = hasMatrix ? svgCanvas.getInitBbox() : getBBox(selected)
227
275
  let left = box.x
@@ -548,10 +596,21 @@ const mouseMoveEvent = (evt) => {
548
596
  *
549
597
  * @returns {void}
550
598
  */
551
- const mouseOutEvent = () => {
599
+ const mouseOutEvent = (evt) => {
552
600
  const { $id } = svgCanvas
553
601
  if (svgCanvas.getCurrentMode() !== 'select' && svgCanvas.getStarted()) {
554
- const event = new Event('mouseup')
602
+ const event = new MouseEvent('mouseup', {
603
+ bubbles: true,
604
+ cancelable: true,
605
+ clientX: evt?.clientX ?? 0,
606
+ clientY: evt?.clientY ?? 0,
607
+ button: evt?.button ?? 0,
608
+ buttons: evt?.buttons ?? 0,
609
+ altKey: evt?.altKey ?? false,
610
+ ctrlKey: evt?.ctrlKey ?? false,
611
+ metaKey: evt?.metaKey ?? false,
612
+ shiftKey: evt?.shiftKey ?? false
613
+ })
555
614
  $id('svgcanvas').dispatchEvent(event)
556
615
  }
557
616
  }
@@ -637,10 +696,73 @@ const mouseUpEvent = (evt) => {
637
696
  }
638
697
  svgCanvas.selectorManager.requestSelector(selected).showGrips(true)
639
698
  }
640
- // always recalculate dimensions to strip off stray identity transforms
641
- svgCanvas.recalculateAllSelectedDimensions()
642
699
  // if it was being dragged/resized
643
700
  if (realX !== svgCanvas.getRStartX() || realY !== svgCanvas.getRStartY()) {
701
+ // Only recalculate dimensions after actual dragging/resizing to avoid
702
+ // unwanted transform flattening on simple clicks
703
+
704
+ // Create a single batch command for all moved elements
705
+ const batchCmd = new BatchCommand('position')
706
+
707
+ selectedElements.forEach((elem) => {
708
+ if (!elem) return
709
+
710
+ const tlist = getTransformList(elem)
711
+ if (!tlist || tlist.numberOfItems === 0) return
712
+
713
+ // Get the transform from BEFORE this interaction started. `dragStartTransforms`
714
+ // is only populated for 'select'-mode (move) drags; 'resize'/'rotate' drags fall
715
+ // through to this same code with dragStartTransforms left null, so fall back to
716
+ // the value captured at mousedown via setStartTransform, which correctly reflects
717
+ // the prior transform (e.g. a matrix left by an earlier move) regardless of mode.
718
+ const oldTransform = svgCanvas.dragStartTransforms?.has(elem)
719
+ ? svgCanvas.dragStartTransforms.get(elem)
720
+ : (svgCanvas.getStartTransform() || '')
721
+
722
+ // Check if the first transform is a translate (the drag transform we added)
723
+ const firstTransform = tlist.getItem(0)
724
+ const hasDragTranslate = firstTransform.type === SVGTransform.SVG_TRANSFORM_TRANSLATE
725
+
726
+ // recalculateDimensions() returns null for groups (their transform must stay
727
+ // on the group itself), so consolidate those directly into one matrix. For
728
+ // every other element type, recalculateDimensions() already understands the
729
+ // translate/scale/translate shape produced by a resize (and plain translate
730
+ // from a move), and flattening into it keeps the shape's real geometry
731
+ // attributes (x/y/width/height/rx/ry/points/d/etc.) in sync - which is what
732
+ // drives the coordinate/size panel inputs and keeps stroke width from being
733
+ // visually distorted by a leftover scale matrix.
734
+ const isGroup = elem.tagName === 'g' || elem.tagName === 'a'
735
+
736
+ if (isGroup && hasDragTranslate) {
737
+ batchCmd.addSubCommand(consolidateTransform(svgCanvas.getSvgRoot(), elem, tlist, oldTransform))
738
+ return
739
+ }
740
+
741
+ const cmd = svgCanvas.recalculateDimensions(elem)
742
+ if (cmd) {
743
+ batchCmd.addSubCommand(cmd)
744
+ } else if (tlist.numberOfItems > 1 && hasDragTranslate) {
745
+ // recalculateDimensions() didn't recognize this transform-list shape;
746
+ // fall back to consolidating into one matrix so the transform isn't lost.
747
+ batchCmd.addSubCommand(consolidateTransform(svgCanvas.getSvgRoot(), elem, tlist, oldTransform))
748
+ } else {
749
+ // recalculateDimensions returned null and there's nothing left to consolidate
750
+ // Check if the transform actually changed and record it manually
751
+ const newTransform = elem.getAttribute('transform') || ''
752
+ if (newTransform !== oldTransform) {
753
+ batchCmd.addSubCommand(new ChangeElementCommand(elem, { transform: oldTransform }))
754
+ }
755
+ }
756
+ })
757
+
758
+ if (!batchCmd.isEmpty()) {
759
+ svgCanvas.addCommandToHistory(batchCmd)
760
+ }
761
+
762
+ // Clear the stored transforms AND reset the flag together
763
+ svgCanvas.dragStartTransforms = null
764
+ svgCanvas.hasDragStartTransform = false
765
+
644
766
  const len = selectedElements.length
645
767
  for (let i = 0; i < len; ++i) {
646
768
  if (!selectedElements[i]) { break }
@@ -799,6 +921,8 @@ const mouseUpEvent = (evt) => {
799
921
  svgCanvas.textActions.mouseUp(evt, mouseX, mouseY)
800
922
  break
801
923
  case 'rotate': {
924
+ svgCanvas.hasDragStartTransform = false
925
+ svgCanvas.dragStartTransforms = null
802
926
  keep = true
803
927
  element = null
804
928
  svgCanvas.setCurrentMode('select')
@@ -812,8 +936,13 @@ const mouseUpEvent = (evt) => {
812
936
  break
813
937
  } default:
814
938
  // This could occur in an extension
939
+ svgCanvas.hasDragStartTransform = false
940
+ svgCanvas.dragStartTransforms = null
815
941
  break
816
942
  }
943
+ // Reset drag flag after any mouseUp
944
+ svgCanvas.hasDragStartTransform = false
945
+ svgCanvas.dragStartTransforms = null
817
946
 
818
947
  /**
819
948
  * The main (left) mouse button is released (anywhere).
@@ -979,7 +1108,13 @@ const mouseDownEvent = (evt) => {
979
1108
  svgCanvas.cloneSelectedElements(0, 0)
980
1109
  }
981
1110
 
982
- svgCanvas.setRootSctm($id('svgcontent').querySelector('g').getScreenCTM().inverse())
1111
+ // Get screenCTM from the first child group of svgcontent
1112
+ // Note: svgcontent itself has x/y offset attributes, so we use its first child
1113
+ const svgContent = $id('svgcontent')
1114
+ const rootGroup = svgContent?.querySelector('g')
1115
+ const screenCTM = rootGroup?.getScreenCTM?.()
1116
+ if (!screenCTM) { return }
1117
+ svgCanvas.setRootSctm(screenCTM.inverse())
983
1118
 
984
1119
  const pt = transformPoint(evt.clientX, evt.clientY, svgCanvas.getrootSctm())
985
1120
  const mouseX = pt.x * zoom
@@ -1039,12 +1174,22 @@ const mouseDownEvent = (evt) => {
1039
1174
  svgCanvas.setStartTransform(mouseTarget.getAttribute('transform'))
1040
1175
 
1041
1176
  const tlist = getTransformList(mouseTarget)
1042
- // consolidate transforms using standard SVG but keep the transformation used for the move/scale
1043
- if (tlist.numberOfItems > 1) {
1044
- const firstTransform = tlist.getItem(0)
1045
- tlist.removeItem(0)
1046
- tlist.consolidate()
1047
- tlist.insertItemBefore(firstTransform, 0)
1177
+
1178
+ // Consolidate transforms for non-group elements to simplify dragging
1179
+ // For elements with multiple transforms (e.g., after ungrouping), consolidate them
1180
+ // into a single matrix so the dummy translate can be properly applied during drag
1181
+ if (tlist?.numberOfItems > 1 && mouseTarget.tagName !== 'g' && mouseTarget.tagName !== 'a') {
1182
+ // Compute the consolidated matrix from all transforms
1183
+ const consolidatedMatrix = transformListToTransform(tlist).matrix
1184
+
1185
+ // Clear the transform list and add a single matrix transform
1186
+ while (tlist.numberOfItems > 0) {
1187
+ tlist.removeItem(0)
1188
+ }
1189
+
1190
+ const newTransform = svgCanvas.getSvgRoot().createSVGTransform()
1191
+ newTransform.setMatrix(consolidatedMatrix)
1192
+ tlist.appendItem(newTransform)
1048
1193
  }
1049
1194
  switch (svgCanvas.getCurrentMode()) {
1050
1195
  case 'select':
@@ -1067,19 +1212,9 @@ const mouseDownEvent = (evt) => {
1067
1212
  }
1068
1213
  // else if it's a path, go into pathedit mode in mouseup
1069
1214
 
1070
- if (!rightClick) {
1071
- // insert a dummy transform so if the element(s) are moved it will have
1072
- // a transform to use for its translate
1073
- for (const selectedElement of selectedElements) {
1074
- if (!selectedElement) { continue }
1075
- const slist = getTransformList(selectedElement)
1076
- if (slist.numberOfItems) {
1077
- slist.insertItemBefore(svgRoot.createSVGTransform(), 0)
1078
- } else {
1079
- slist.appendItem(svgRoot.createSVGTransform())
1080
- }
1081
- }
1082
- }
1215
+ // Note: Dummy transform insertion moved to mouseMove to avoid triggering
1216
+ // recalculateDimensions on simple clicks. The dummy transform is only needed
1217
+ // when actually starting a drag operation.
1083
1218
  } else if (!rightClick) {
1084
1219
  svgCanvas.clearSelection()
1085
1220
  svgCanvas.setCurrentMode('multiselect')
@@ -1105,13 +1240,14 @@ const mouseDownEvent = (evt) => {
1105
1240
  }
1106
1241
  assignAttributes(svgCanvas.getRubberBox(), {
1107
1242
  x: realX * zoom,
1108
- y: realX * zoom,
1243
+ y: realY * zoom,
1109
1244
  width: 0,
1110
1245
  height: 0,
1111
1246
  display: 'inline'
1112
1247
  }, 100)
1113
1248
  break
1114
1249
  case 'resize': {
1250
+ if (!tlist) { break }
1115
1251
  svgCanvas.setStarted(true)
1116
1252
  svgCanvas.setStartX(x)
1117
1253
  svgCanvas.setStartY(y)
@@ -1339,7 +1475,13 @@ const DOMMouseScrollEvent = (e) => {
1339
1475
 
1340
1476
  e.preventDefault()
1341
1477
 
1342
- svgCanvas.setRootSctm($id('svgcontent').querySelector('g').getScreenCTM().inverse())
1478
+ // Get screenCTM from the first child group of svgcontent
1479
+ // Note: svgcontent itself has x/y offset attributes, so we use its first child
1480
+ const svgContent = $id('svgcontent')
1481
+ const rootGroup = svgContent?.querySelector('g')
1482
+ const screenCTM = rootGroup?.getScreenCTM?.()
1483
+ if (!screenCTM) { return }
1484
+ svgCanvas.setRootSctm(screenCTM.inverse())
1343
1485
 
1344
1486
  const workarea = document.getElementById('workarea')
1345
1487
  const scrbar = 15
package/core/history.js CHANGED
@@ -6,7 +6,63 @@
6
6
  * @copyright 2010 Jeff Schiller
7
7
  */
8
8
 
9
+ import { NS } from './namespaces.js'
9
10
  import { getHref, setHref, getRotationAngle, getBBox } from './utilities.js'
11
+ import { getTransformList, transformListToTransform, transformPoint } from './math.js'
12
+
13
+ // Attributes that affect an element's bounding box. Only these require
14
+ // recalculating the rotation center when changed.
15
+ export const BBOX_AFFECTING_ATTRS = new Set([
16
+ 'x', 'y', 'x1', 'y1', 'x2', 'y2',
17
+ 'cx', 'cy', 'r', 'rx', 'ry',
18
+ 'width', 'height', 'd', 'points'
19
+ ])
20
+
21
+ /**
22
+ * Relocate rotation center after a bbox-affecting attribute change.
23
+ * Uses the transform list API to update only the rotation entry,
24
+ * preserving compound transforms (translate, scale, etc.).
25
+ * @param {Element} elem - SVG element
26
+ * @param {string[]} changedAttrs - attribute names that were changed
27
+ */
28
+ function relocateRotationCenter (elem, changedAttrs) {
29
+ const hasBboxChange = changedAttrs.some(attr => BBOX_AFFECTING_ATTRS.has(attr))
30
+ if (!hasBboxChange) return
31
+
32
+ const angle = getRotationAngle(elem)
33
+ if (!angle) return
34
+
35
+ const tlist = getTransformList(elem)
36
+ let n = tlist.numberOfItems
37
+ while (n--) {
38
+ const xform = tlist.getItem(n)
39
+ if (xform.type === 4) { // SVG_TRANSFORM_ROTATE
40
+ // Compute bbox BEFORE removing the rotation so we can bail out
41
+ // safely if getBBox returns nothing (avoids losing the rotation).
42
+ const box = getBBox(elem)
43
+ if (!box) return
44
+
45
+ tlist.removeItem(n)
46
+
47
+ // Transform bbox center through only post-rotation transforms.
48
+ // After removeItem(n), what was at n+1 is now at n.
49
+ let centerMatrix
50
+ if (n < tlist.numberOfItems) {
51
+ centerMatrix = transformListToTransform(tlist, n, tlist.numberOfItems - 1).matrix
52
+ } else {
53
+ centerMatrix = elem.ownerSVGElement.createSVGMatrix() // identity
54
+ }
55
+ const center = transformPoint(
56
+ box.x + box.width / 2, box.y + box.height / 2, centerMatrix
57
+ )
58
+
59
+ const newrot = elem.ownerSVGElement.createSVGTransform()
60
+ newrot.setRotate(angle, center.x, center.y)
61
+ tlist.insertItemBefore(newrot, n)
62
+ break
63
+ }
64
+ }
65
+ }
10
66
 
11
67
  /**
12
68
  * Group: Undo/Redo history management.
@@ -140,7 +196,7 @@ export class MoveElementCommand extends Command {
140
196
  constructor (elem, oldNextSibling, oldParent, text) {
141
197
  super()
142
198
  this.elem = elem
143
- this.text = text ? ('Move ' + elem.tagName + ' to ' + text) : ('Move ' + elem.tagName)
199
+ this.text = text ? `Move ${elem.tagName} to ${text}` : `Move ${elem.tagName}`
144
200
  this.oldNextSibling = oldNextSibling
145
201
  this.oldParent = oldParent
146
202
  this.newNextSibling = elem.nextSibling
@@ -155,7 +211,11 @@ export class MoveElementCommand extends Command {
155
211
  */
156
212
  apply (handler) {
157
213
  super.apply(handler, () => {
158
- this.elem = this.newParent.insertBefore(this.elem, this.newNextSibling)
214
+ const reference =
215
+ this.newNextSibling && this.newNextSibling.parentNode === this.newParent
216
+ ? this.newNextSibling
217
+ : null
218
+ this.elem = this.newParent.insertBefore(this.elem, reference)
159
219
  })
160
220
  }
161
221
 
@@ -167,7 +227,11 @@ export class MoveElementCommand extends Command {
167
227
  */
168
228
  unapply (handler) {
169
229
  super.unapply(handler, () => {
170
- this.elem = this.oldParent.insertBefore(this.elem, this.oldNextSibling)
230
+ const reference =
231
+ this.oldNextSibling && this.oldNextSibling.parentNode === this.oldParent
232
+ ? this.oldNextSibling
233
+ : null
234
+ this.elem = this.oldParent.insertBefore(this.elem, reference)
171
235
  })
172
236
  }
173
237
  }
@@ -184,7 +248,7 @@ export class InsertElementCommand extends Command {
184
248
  constructor (elem, text) {
185
249
  super()
186
250
  this.elem = elem
187
- this.text = text || ('Create ' + elem.tagName)
251
+ this.text = text || `Create ${elem.tagName}`
188
252
  this.parent = elem.parentNode
189
253
  this.nextSibling = this.elem.nextSibling
190
254
  }
@@ -197,7 +261,11 @@ export class InsertElementCommand extends Command {
197
261
  */
198
262
  apply (handler) {
199
263
  super.apply(handler, () => {
200
- this.elem = this.parent.insertBefore(this.elem, this.nextSibling)
264
+ const reference =
265
+ this.nextSibling && this.nextSibling.parentNode === this.parent
266
+ ? this.nextSibling
267
+ : null
268
+ this.elem = this.parent.insertBefore(this.elem, reference)
201
269
  })
202
270
  }
203
271
 
@@ -229,7 +297,7 @@ export class RemoveElementCommand extends Command {
229
297
  constructor (elem, oldNextSibling, oldParent, text) {
230
298
  super()
231
299
  this.elem = elem
232
- this.text = text || ('Delete ' + elem.tagName)
300
+ this.text = text || `Delete ${elem.tagName}`
233
301
  this.nextSibling = oldNextSibling
234
302
  this.parent = oldParent
235
303
  }
@@ -255,10 +323,11 @@ export class RemoveElementCommand extends Command {
255
323
  */
256
324
  unapply (handler) {
257
325
  super.unapply(handler, () => {
258
- if (!this.nextSibling) {
259
- console.error('Reference element was lost')
260
- }
261
- this.parent.insertBefore(this.elem, this.nextSibling) // Don't use `before` or `prepend` as `this.nextSibling` may be `null`
326
+ const reference =
327
+ this.nextSibling && this.nextSibling.parentNode === this.parent
328
+ ? this.nextSibling
329
+ : null
330
+ this.parent.insertBefore(this.elem, reference) // Don't use `before` or `prepend` as `reference` may be `null`
262
331
  })
263
332
  }
264
333
  }
@@ -284,7 +353,7 @@ export class ChangeElementCommand extends Command {
284
353
  constructor (elem, attrs, text) {
285
354
  super()
286
355
  this.elem = elem
287
- this.text = text ? ('Change ' + elem.tagName + ' ' + text) : ('Change ' + elem.tagName)
356
+ this.text = text ? `Change ${elem.tagName} ${text}` : `Change ${elem.tagName}`
288
357
  this.newValues = {}
289
358
  this.oldValues = attrs
290
359
  for (const attr in attrs) {
@@ -308,19 +377,21 @@ export class ChangeElementCommand extends Command {
308
377
  super.apply(handler, () => {
309
378
  let bChangedTransform = false
310
379
  Object.entries(this.newValues).forEach(([attr, value]) => {
311
- if (value) {
312
- if (attr === '#text') {
313
- this.elem.textContent = value
314
- } else if (attr === '#href') {
315
- setHref(this.elem, value)
380
+ const isNullishOrEmpty = value === null || value === undefined || value === ''
381
+ if (attr === '#text') {
382
+ this.elem.textContent = value === null || value === undefined ? '' : String(value)
383
+ } else if (attr === '#href') {
384
+ if (isNullishOrEmpty) {
385
+ this.elem.removeAttribute('href')
386
+ this.elem.removeAttributeNS(NS.XLINK, 'href')
316
387
  } else {
317
- this.elem.setAttribute(attr, value)
388
+ setHref(this.elem, String(value))
318
389
  }
319
- } else if (attr === '#text') {
320
- this.elem.textContent = ''
321
- } else {
390
+ } else if (isNullishOrEmpty) {
322
391
  this.elem.setAttribute(attr, '')
323
392
  this.elem.removeAttribute(attr)
393
+ } else {
394
+ this.elem.setAttribute(attr, value)
324
395
  }
325
396
 
326
397
  if (attr === 'transform') { bChangedTransform = true }
@@ -328,16 +399,7 @@ export class ChangeElementCommand extends Command {
328
399
 
329
400
  // relocate rotational transform, if necessary
330
401
  if (!bChangedTransform) {
331
- const angle = getRotationAngle(this.elem)
332
- if (angle) {
333
- const bbox = getBBox(this.elem)
334
- const cx = bbox.x + bbox.width / 2
335
- const cy = bbox.y + bbox.height / 2
336
- const rotate = ['rotate(', angle, ' ', cx, ',', cy, ')'].join('')
337
- if (rotate !== this.elem.getAttribute('transform')) {
338
- this.elem.setAttribute('transform', rotate)
339
- }
340
- }
402
+ relocateRotationCenter(this.elem, Object.keys(this.newValues))
341
403
  }
342
404
  })
343
405
  }
@@ -352,33 +414,26 @@ export class ChangeElementCommand extends Command {
352
414
  super.unapply(handler, () => {
353
415
  let bChangedTransform = false
354
416
  Object.entries(this.oldValues).forEach(([attr, value]) => {
355
- if (value) {
356
- if (attr === '#text') {
357
- this.elem.textContent = value
358
- } else if (attr === '#href') {
359
- setHref(this.elem, value)
417
+ const isNullishOrEmpty = value === null || value === undefined || value === ''
418
+ if (attr === '#text') {
419
+ this.elem.textContent = value === null || value === undefined ? '' : String(value)
420
+ } else if (attr === '#href') {
421
+ if (isNullishOrEmpty) {
422
+ this.elem.removeAttribute('href')
423
+ this.elem.removeAttributeNS(NS.XLINK, 'href')
360
424
  } else {
361
- this.elem.setAttribute(attr, value)
425
+ setHref(this.elem, String(value))
362
426
  }
363
- } else if (attr === '#text') {
364
- this.elem.textContent = ''
365
- } else {
427
+ } else if (isNullishOrEmpty) {
366
428
  this.elem.removeAttribute(attr)
429
+ } else {
430
+ this.elem.setAttribute(attr, value)
367
431
  }
368
432
  if (attr === 'transform') { bChangedTransform = true }
369
433
  })
370
434
  // relocate rotational transform, if necessary
371
435
  if (!bChangedTransform) {
372
- const angle = getRotationAngle(this.elem)
373
- if (angle) {
374
- const bbox = getBBox(this.elem)
375
- const cx = bbox.x + bbox.width / 2
376
- const cy = bbox.y + bbox.height / 2
377
- const rotate = ['rotate(', angle, ' ', cx, ',', cy, ')'].join('')
378
- if (rotate !== this.elem.getAttribute('transform')) {
379
- this.elem.setAttribute('transform', rotate)
380
- }
381
- }
436
+ relocateRotationCenter(this.elem, Object.keys(this.oldValues))
382
437
  }
383
438
  })
384
439
  }
@@ -602,7 +657,7 @@ export class UndoManager {
602
657
  const p = this.undoChangeStackPointer--
603
658
  const changeset = this.undoableChangeStack[p]
604
659
  const { attrName } = changeset
605
- const batchCmd = new BatchCommand('Change ' + attrName)
660
+ const batchCmd = new BatchCommand(`Change ${attrName}`)
606
661
  let i = changeset.elements.length
607
662
  while (i--) {
608
663
  const elem = changeset.elements[i]
@@ -79,7 +79,9 @@ class HistoryRecordingService {
79
79
  this.batchCommandStack_.pop()
80
80
  const { length: len } = this.batchCommandStack_
81
81
  this.currentBatchCommand_ = len ? this.batchCommandStack_[len - 1] : null
82
- this.addCommand_(batchCommand)
82
+ if (!batchCommand.isEmpty()) {
83
+ this.addCommand_(batchCommand)
84
+ }
83
85
  }
84
86
  return this
85
87
  }
@@ -157,5 +159,5 @@ class HistoryRecordingService {
157
159
  * @memberof module:history.HistoryRecordingService
158
160
  * @property {module:history.HistoryRecordingService} NO_HISTORY - Singleton that can be passed to functions that record history, but the caller requires that no history be recorded.
159
161
  */
160
- HistoryRecordingService.NO_HISTORY = new HistoryRecordingService()
162
+ HistoryRecordingService.NO_HISTORY = new HistoryRecordingService(null)
161
163
  export default HistoryRecordingService