@svgedit/svgcanvas 7.4.1 → 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.
@@ -655,14 +655,31 @@ const setStrokeAttrMethod = (attr, val) => {
655
655
  }
656
656
  }
657
657
 
658
+ const getSelectedTextElements = () => {
659
+ return svgCanvas.getSelectedElements().filter(el => el?.tagName === 'text')
660
+ }
661
+
662
+ const getChangedTextElements = (textElements, attr, newValue) => {
663
+ const normalizedValue = String(newValue)
664
+ return textElements.filter((elem) => {
665
+ const oldValue = attr === '#text' ? elem.textContent : elem.getAttribute(attr)
666
+ return (oldValue || '') !== normalizedValue
667
+ })
668
+ }
669
+
670
+ const notifyTextChange = (textElements) => {
671
+ if (textElements.length > 0) {
672
+ svgCanvas.call('changed', textElements)
673
+ }
674
+ }
675
+
658
676
  /**
659
677
  * Check if all selected text elements are in bold.
660
678
  * @function module:svgcanvas.SvgCanvas#getBold
661
679
  * @returns {boolean} `true` if all selected elements are bold, `false` otherwise.
662
680
  */
663
681
  const getBoldMethod = () => {
664
- const selectedElements = svgCanvas.getSelectedElements()
665
- const textElements = selectedElements.filter(el => el?.tagName === 'text')
682
+ const textElements = getSelectedTextElements()
666
683
  return textElements.every(el => el.getAttribute('font-weight') === 'bold')
667
684
  }
668
685
 
@@ -673,12 +690,16 @@ const getBoldMethod = () => {
673
690
  * @returns {void}
674
691
  */
675
692
  const setBoldMethod = (b) => {
676
- const selectedElements = svgCanvas.getSelectedElements()
677
- const textElements = selectedElements.filter(el => el?.tagName === 'text')
678
- svgCanvas.changeSelectedAttribute('font-weight', b ? 'bold' : 'normal', textElements)
693
+ const textElements = getSelectedTextElements()
694
+ const value = b ? 'bold' : 'normal'
695
+ const changedTextElements = getChangedTextElements(textElements, 'font-weight', value)
696
+ if (changedTextElements.length > 0) {
697
+ svgCanvas.changeSelectedAttribute('font-weight', value, changedTextElements)
698
+ }
679
699
  if (!textElements.some(el => el.textContent)) {
680
700
  svgCanvas.textActions.setCursor()
681
701
  }
702
+ notifyTextChange(changedTextElements)
682
703
  }
683
704
 
684
705
  /**
@@ -686,8 +707,7 @@ const setBoldMethod = (b) => {
686
707
  * @returns {boolean} Indicates whether or not elements have the text decoration value
687
708
  */
688
709
  const hasTextDecorationMethod = (value) => {
689
- const selectedElements = svgCanvas.getSelectedElements()
690
- const textElements = selectedElements.filter(el => el?.tagName === 'text')
710
+ const textElements = getSelectedTextElements()
691
711
  return textElements.every(el => (el.getAttribute('text-decoration') || '').includes(value))
692
712
  }
693
713
 
@@ -698,8 +718,7 @@ const hasTextDecorationMethod = (value) => {
698
718
  */
699
719
  const addTextDecorationMethod = (value) => {
700
720
  const { ChangeElementCommand, BatchCommand } = svgCanvas.history
701
- const selectedElements = svgCanvas.getSelectedElements()
702
- const textElements = selectedElements.filter(el => el?.tagName === 'text')
721
+ const textElements = getSelectedTextElements()
703
722
 
704
723
  const batchCmd = new BatchCommand()
705
724
  textElements.forEach(elem => {
@@ -726,8 +745,7 @@ const addTextDecorationMethod = (value) => {
726
745
  */
727
746
  const removeTextDecorationMethod = (value) => {
728
747
  const { ChangeElementCommand, BatchCommand } = svgCanvas.history
729
- const selectedElements = svgCanvas.getSelectedElements()
730
- const textElements = selectedElements.filter(el => el?.tagName === 'text')
748
+ const textElements = getSelectedTextElements()
731
749
 
732
750
  const batchCmd = new BatchCommand()
733
751
  textElements.forEach(elem => {
@@ -750,8 +768,7 @@ const removeTextDecorationMethod = (value) => {
750
768
  * @returns {boolean} `true` if all selected elements are in italics, `false` otherwise.
751
769
  */
752
770
  const getItalicMethod = () => {
753
- const selectedElements = svgCanvas.getSelectedElements()
754
- const textElements = selectedElements.filter(el => el?.tagName === 'text')
771
+ const textElements = getSelectedTextElements()
755
772
  return textElements.every(el => el.getAttribute('font-style') === 'italic')
756
773
  }
757
774
 
@@ -762,12 +779,16 @@ const getItalicMethod = () => {
762
779
  * @returns {void}
763
780
  */
764
781
  const setItalicMethod = (i) => {
765
- const selectedElements = svgCanvas.getSelectedElements()
766
- const textElements = selectedElements.filter(el => el?.tagName === 'text')
767
- svgCanvas.changeSelectedAttribute('font-style', i ? 'italic' : 'normal', textElements)
782
+ const textElements = getSelectedTextElements()
783
+ const value = i ? 'italic' : 'normal'
784
+ const changedTextElements = getChangedTextElements(textElements, 'font-style', value)
785
+ if (changedTextElements.length > 0) {
786
+ svgCanvas.changeSelectedAttribute('font-style', value, changedTextElements)
787
+ }
768
788
  if (!textElements.some(el => el.textContent)) {
769
789
  svgCanvas.textActions.setCursor()
770
790
  }
791
+ notifyTextChange(changedTextElements)
771
792
  }
772
793
 
773
794
  /**
@@ -776,9 +797,12 @@ const setItalicMethod = (i) => {
776
797
  * @returns {void}
777
798
  */
778
799
  const setTextAnchorMethod = (value) => {
779
- const selectedElements = svgCanvas.getSelectedElements()
780
- const textElements = selectedElements.filter(el => el?.tagName === 'text')
781
- svgCanvas.changeSelectedAttribute('text-anchor', value, textElements)
800
+ const textElements = getSelectedTextElements()
801
+ const changedTextElements = getChangedTextElements(textElements, 'text-anchor', value)
802
+ if (changedTextElements.length > 0) {
803
+ svgCanvas.changeSelectedAttribute('text-anchor', value, changedTextElements)
804
+ }
805
+ notifyTextChange(changedTextElements)
782
806
  }
783
807
 
784
808
  /**
@@ -787,12 +811,15 @@ const setTextAnchorMethod = (value) => {
787
811
  * @returns {void}
788
812
  */
789
813
  const setLetterSpacingMethod = (value) => {
790
- const selectedElements = svgCanvas.getSelectedElements()
791
- const textElements = selectedElements.filter(el => el?.tagName === 'text')
792
- svgCanvas.changeSelectedAttribute('letter-spacing', value, textElements)
814
+ const textElements = getSelectedTextElements()
815
+ const changedTextElements = getChangedTextElements(textElements, 'letter-spacing', value)
816
+ if (changedTextElements.length > 0) {
817
+ svgCanvas.changeSelectedAttribute('letter-spacing', value, changedTextElements)
818
+ }
793
819
  if (!textElements.some(el => el.textContent)) {
794
820
  svgCanvas.textActions.setCursor()
795
821
  }
822
+ notifyTextChange(changedTextElements)
796
823
  }
797
824
 
798
825
  /**
@@ -801,12 +828,15 @@ const setLetterSpacingMethod = (value) => {
801
828
  * @returns {void}
802
829
  */
803
830
  const setWordSpacingMethod = (value) => {
804
- const selectedElements = svgCanvas.getSelectedElements()
805
- const textElements = selectedElements.filter(el => el?.tagName === 'text')
806
- svgCanvas.changeSelectedAttribute('word-spacing', value, textElements)
831
+ const textElements = getSelectedTextElements()
832
+ const changedTextElements = getChangedTextElements(textElements, 'word-spacing', value)
833
+ if (changedTextElements.length > 0) {
834
+ svgCanvas.changeSelectedAttribute('word-spacing', value, changedTextElements)
835
+ }
807
836
  if (!textElements.some(el => el.textContent)) {
808
837
  svgCanvas.textActions.setCursor()
809
838
  }
839
+ notifyTextChange(changedTextElements)
810
840
  }
811
841
 
812
842
  /**
@@ -815,12 +845,15 @@ const setWordSpacingMethod = (value) => {
815
845
  * @returns {void}
816
846
  */
817
847
  const setTextLengthMethod = (value) => {
818
- const selectedElements = svgCanvas.getSelectedElements()
819
- const textElements = selectedElements.filter(el => el?.tagName === 'text')
820
- svgCanvas.changeSelectedAttribute('textLength', value, textElements)
848
+ const textElements = getSelectedTextElements()
849
+ const changedTextElements = getChangedTextElements(textElements, 'textLength', value)
850
+ if (changedTextElements.length > 0) {
851
+ svgCanvas.changeSelectedAttribute('textLength', value, changedTextElements)
852
+ }
821
853
  if (!textElements.some(el => el.textContent)) {
822
854
  svgCanvas.textActions.setCursor()
823
855
  }
856
+ notifyTextChange(changedTextElements)
824
857
  }
825
858
 
826
859
  /**
@@ -829,12 +862,15 @@ const setTextLengthMethod = (value) => {
829
862
  * @returns {void}
830
863
  */
831
864
  const setLengthAdjustMethod = (value) => {
832
- const selectedElements = svgCanvas.getSelectedElements()
833
- const textElements = selectedElements.filter(el => el?.tagName === 'text')
834
- svgCanvas.changeSelectedAttribute('lengthAdjust', value, textElements)
865
+ const textElements = getSelectedTextElements()
866
+ const changedTextElements = getChangedTextElements(textElements, 'lengthAdjust', value)
867
+ if (changedTextElements.length > 0) {
868
+ svgCanvas.changeSelectedAttribute('lengthAdjust', value, changedTextElements)
869
+ }
835
870
  if (!textElements.some(el => el.textContent)) {
836
871
  svgCanvas.textActions.setCursor()
837
872
  }
873
+ notifyTextChange(changedTextElements)
838
874
  }
839
875
 
840
876
  /**
@@ -852,13 +888,16 @@ const getFontFamilyMethod = () => {
852
888
  * @returns {void}
853
889
  */
854
890
  const setFontFamilyMethod = (val) => {
855
- const selectedElements = svgCanvas.getSelectedElements()
856
- const textElements = selectedElements.filter(el => el?.tagName === 'text')
891
+ const textElements = getSelectedTextElements()
892
+ const changedTextElements = getChangedTextElements(textElements, 'font-family', val)
857
893
  svgCanvas.setCurText('font_family', val)
858
- svgCanvas.changeSelectedAttribute('font-family', val, textElements)
894
+ if (changedTextElements.length > 0) {
895
+ svgCanvas.changeSelectedAttribute('font-family', val, changedTextElements)
896
+ }
859
897
  if (!textElements.some(el => el.textContent)) {
860
898
  svgCanvas.textActions.setCursor()
861
899
  }
900
+ notifyTextChange(changedTextElements)
862
901
  }
863
902
 
864
903
  /**
@@ -868,8 +907,13 @@ const setFontFamilyMethod = (val) => {
868
907
  * @returns {void}
869
908
  */
870
909
  const setFontColorMethod = (val) => {
910
+ const textElements = getSelectedTextElements()
911
+ const changedTextElements = getChangedTextElements(textElements, 'fill', val)
871
912
  svgCanvas.setCurText('fill', val)
872
- svgCanvas.changeSelectedAttribute('fill', val)
913
+ if (changedTextElements.length > 0) {
914
+ svgCanvas.changeSelectedAttribute('fill', val, changedTextElements)
915
+ }
916
+ notifyTextChange(changedTextElements)
873
917
  }
874
918
 
875
919
  /**
@@ -895,12 +939,16 @@ const getFontSizeMethod = () => {
895
939
  * @returns {void}
896
940
  */
897
941
  const setFontSizeMethod = (val) => {
898
- const selectedElements = svgCanvas.getSelectedElements()
942
+ const textElements = getSelectedTextElements()
943
+ const changedTextElements = getChangedTextElements(textElements, 'font-size', val)
899
944
  svgCanvas.setCurText('font_size', val)
900
- svgCanvas.changeSelectedAttribute('font-size', val)
901
- if (!selectedElements[0]?.textContent) {
945
+ if (changedTextElements.length > 0) {
946
+ svgCanvas.changeSelectedAttribute('font-size', val, changedTextElements)
947
+ }
948
+ if (!textElements.some(el => el.textContent)) {
902
949
  svgCanvas.textActions.setCursor()
903
950
  }
951
+ notifyTextChange(changedTextElements)
904
952
  }
905
953
 
906
954
  /**
package/core/event.js CHANGED
@@ -89,7 +89,7 @@ const updateTransformList = (svgRoot, element, dx, dy) => {
89
89
  if (!tlist) { return }
90
90
  if (tlist.numberOfItems) {
91
91
  const firstItem = tlist.getItem(0)
92
- if (firstItem.type === 2) { // SVG_TRANSFORM_TRANSLATE = 2
92
+ if (firstItem.type === SVGTransform.SVG_TRANSFORM_TRANSLATE) {
93
93
  tlist.replaceItem(xform, 0)
94
94
  } else {
95
95
  tlist.insertItemBefore(xform, 0)
@@ -99,6 +99,25 @@ const updateTransformList = (svgRoot, element, dx, dy) => {
99
99
  }
100
100
  }
101
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
+
102
121
  /**
103
122
  *
104
123
  * @param {MouseEvent} evt
@@ -159,10 +178,16 @@ const mouseMoveEvent = (evt) => {
159
178
  svgCanvas.dragStartTransforms.set(selectedElement, selectedElement.getAttribute('transform') || '')
160
179
  const slist = getTransformList(selectedElement)
161
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)
162
187
  if (slist.numberOfItems) {
163
- slist.insertItemBefore(svgRoot.createSVGTransform(), 0)
188
+ slist.insertItemBefore(dummy, 0)
164
189
  } else {
165
- slist.appendItem(svgRoot.createSVGTransform())
190
+ slist.appendItem(dummy)
166
191
  }
167
192
  }
168
193
  svgCanvas.hasDragStartTransform = true
@@ -685,41 +710,43 @@ const mouseUpEvent = (evt) => {
685
710
  const tlist = getTransformList(elem)
686
711
  if (!tlist || tlist.numberOfItems === 0) return
687
712
 
688
- // Get the transform from BEFORE the drag started
689
- const oldTransform = svgCanvas.dragStartTransforms?.get(elem) || ''
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() || '')
690
721
 
691
722
  // Check if the first transform is a translate (the drag transform we added)
692
723
  const firstTransform = tlist.getItem(0)
693
- const hasDragTranslate = firstTransform.type === 2 // SVG_TRANSFORM_TRANSLATE
694
-
695
- // For groups, we always consolidate the transforms (recalculateDimensions returns null for groups)
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.
696
734
  const isGroup = elem.tagName === 'g' || elem.tagName === 'a'
697
735
 
698
- // If element has 2+ transforms, or is a group with a drag translate, consolidate
699
- if ((tlist.numberOfItems > 1 && hasDragTranslate) || (isGroup && hasDragTranslate)) {
700
- const consolidatedMatrix = transformListToTransform(tlist).matrix
701
-
702
- // Clear the transform list
703
- while (tlist.numberOfItems > 0) {
704
- tlist.removeItem(0)
705
- }
706
-
707
- // Add the consolidated matrix
708
- const newTransform = svgCanvas.getSvgRoot().createSVGTransform()
709
- newTransform.setMatrix(consolidatedMatrix)
710
- tlist.appendItem(newTransform)
711
-
712
- // Record the transform change for undo
713
- batchCmd.addSubCommand(new ChangeElementCommand(elem, { transform: oldTransform }))
736
+ if (isGroup && hasDragTranslate) {
737
+ batchCmd.addSubCommand(consolidateTransform(svgCanvas.getSvgRoot(), elem, tlist, oldTransform))
714
738
  return
715
739
  }
716
740
 
717
- // For non-group elements with simple transforms, try recalculateDimensions
718
741
  const cmd = svgCanvas.recalculateDimensions(elem)
719
742
  if (cmd) {
720
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))
721
748
  } else {
722
- // recalculateDimensions returned null
749
+ // recalculateDimensions returned null and there's nothing left to consolidate
723
750
  // Check if the transform actually changed and record it manually
724
751
  const newTransform = elem.getAttribute('transform') || ''
725
752
  if (newTransform !== oldTransform) {
package/core/history.js CHANGED
@@ -8,6 +8,61 @@
8
8
 
9
9
  import { NS } from './namespaces.js'
10
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
+ }
11
66
 
12
67
  /**
13
68
  * Group: Undo/Redo history management.
@@ -344,17 +399,7 @@ export class ChangeElementCommand extends Command {
344
399
 
345
400
  // relocate rotational transform, if necessary
346
401
  if (!bChangedTransform) {
347
- const angle = getRotationAngle(this.elem)
348
- if (angle) {
349
- const bbox = getBBox(this.elem)
350
- if (!bbox) return
351
- const cx = bbox.x + bbox.width / 2
352
- const cy = bbox.y + bbox.height / 2
353
- const rotate = ['rotate(', angle, ' ', cx, ',', cy, ')'].join('')
354
- if (rotate !== this.elem.getAttribute('transform')) {
355
- this.elem.setAttribute('transform', rotate)
356
- }
357
- }
402
+ relocateRotationCenter(this.elem, Object.keys(this.newValues))
358
403
  }
359
404
  })
360
405
  }
@@ -388,17 +433,7 @@ export class ChangeElementCommand extends Command {
388
433
  })
389
434
  // relocate rotational transform, if necessary
390
435
  if (!bChangedTransform) {
391
- const angle = getRotationAngle(this.elem)
392
- if (angle) {
393
- const bbox = getBBox(this.elem)
394
- if (!bbox) return
395
- const cx = bbox.x + bbox.width / 2
396
- const cy = bbox.y + bbox.height / 2
397
- const rotate = ['rotate(', angle, ' ', cx, ',', cy, ')'].join('')
398
- if (rotate !== this.elem.getAttribute('transform')) {
399
- this.elem.setAttribute('transform', rotate)
400
- }
401
- }
436
+ relocateRotationCenter(this.elem, Object.keys(this.oldValues))
402
437
  }
403
438
  })
404
439
  }
package/core/sanitize.js CHANGED
@@ -11,6 +11,7 @@ import { getHref, getRefElem, setHref, getUrlFromAttr } from './utilities.js'
11
11
  import { warn } from '../common/logger.js'
12
12
 
13
13
  const REVERSE_NS = getReverseNS()
14
+ const FONT_ATTRIBUTES = ['font-family', 'font-size', 'font-stretch', 'font-style', 'font-weight']
14
15
 
15
16
  // Todo: Split out into core attributes, presentation attributes, etc. so consistent
16
17
  /**
@@ -39,7 +40,7 @@ const svgWhiteList_ = {
39
40
  feOffset: ['dx', 'in', 'dy', 'result'],
40
41
  filter: ['color-interpolation-filters', 'filterRes', 'filterUnits', 'height', 'href', 'primitiveUnits', 'requiredFeatures', 'width', 'x', 'xlink:href', 'y'],
41
42
  foreignObject: ['font-size', 'height', 'opacity', 'requiredFeatures', 'width', 'x', 'y'],
42
- g: ['clip-path', 'clip-rule', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'mask', 'opacity', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'font-family', 'font-size', 'font-style', 'font-weight', 'text-anchor'],
43
+ g: [...FONT_ATTRIBUTES, 'clip-path', 'clip-rule', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'mask', 'opacity', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'text-anchor'],
43
44
  image: [
44
45
  'clip-path', 'clip-rule', 'filter', 'height', 'mask', 'opacity',
45
46
  'preserveAspectRatio', 'requiredFeatures', 'systemLanguage', 'viewBox',
@@ -60,11 +61,11 @@ const svgWhiteList_ = {
60
61
  style: ['type'],
61
62
  svg: ['clip-path', 'clip-rule', 'enable-background', 'filter', 'height', 'mask', 'preserveAspectRatio', 'requiredFeatures', 'systemLanguage', 'version', 'viewBox', 'width', 'x', 'xmlns', 'xmlns:se', 'xmlns:xlink', 'xmlns:oi', 'oi:animations', 'y', 'stroke-linejoin', 'fill-rule', 'aria-label', 'stroke-width', 'fill-rule', 'xml:space'],
62
63
  switch: ['requiredFeatures', 'systemLanguage'],
63
- symbol: ['fill', 'fill-opacity', 'fill-rule', 'filter', 'font-family', 'font-size', 'font-style', 'font-weight', 'opacity', 'overflow', 'preserveAspectRatio', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'viewBox', 'width', 'height'],
64
- text: ['clip-path', 'clip-rule', 'dominant-baseline', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'font-family', 'font-size', 'font-style', 'font-weight', 'mask', 'opacity', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'text-anchor', 'letter-spacing', 'word-spacing', 'text-decoration', 'textLength', 'lengthAdjust', 'x', 'xml:space', 'y'],
64
+ symbol: [...FONT_ATTRIBUTES, 'fill', 'fill-opacity', 'fill-rule', 'filter', 'opacity', 'overflow', 'preserveAspectRatio', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'viewBox', 'width', 'height'],
65
+ text: [...FONT_ATTRIBUTES, 'clip-path', 'clip-rule', 'dominant-baseline', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'mask', 'opacity', 'requiredFeatures', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'text-anchor', 'letter-spacing', 'word-spacing', 'text-decoration', 'textLength', 'lengthAdjust', 'x', 'xml:space', 'y'],
65
66
  textPath: ['dominant-baseline', 'href', 'method', 'requiredFeatures', 'spacing', 'startOffset', 'systemLanguage', 'xlink:href'],
66
67
  title: [],
67
- tspan: ['clip-path', 'clip-rule', 'dx', 'dy', 'dominant-baseline', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'font-family', 'font-size', 'font-style', 'font-weight', 'mask', 'opacity', 'requiredFeatures', 'rotate', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'text-anchor', 'textLength', 'x', 'xml:space', 'y'],
68
+ tspan: [...FONT_ATTRIBUTES, 'clip-path', 'clip-rule', 'dx', 'dy', 'dominant-baseline', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'mask', 'opacity', 'requiredFeatures', 'rotate', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'systemLanguage', 'text-anchor', 'textLength', 'x', 'xml:space', 'y'],
68
69
  use: ['clip-path', 'clip-rule', 'fill', 'fill-opacity', 'fill-rule', 'filter', 'height', 'href', 'mask', 'stroke', 'stroke-dasharray', 'stroke-dashoffset', 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', 'stroke-width', 'width', 'x', 'xlink:href', 'y', 'overflow'],
69
70
  // Filter Primitives
70
71
  feComponentTransfer: ['in', 'result'],
@@ -1069,6 +1069,7 @@ const convertToGroup = elem => {
1069
1069
  tlist.appendItem(xform)
1070
1070
  recalculateDimensions(elem)
1071
1071
  svgCanvas.call('selected', [elem])
1072
+ return elem
1072
1073
  } else if (dataStorage.has($elem, 'symbol')) {
1073
1074
  elem = dataStorage.get($elem, 'symbol')
1074
1075
  if (!elem) {
@@ -1203,8 +1204,10 @@ const convertToGroup = elem => {
1203
1204
  }
1204
1205
 
1205
1206
  svgCanvas.addCommandToHistory(batchCmd)
1207
+ return g
1206
1208
  } else {
1207
1209
  warn('Unexpected element to ungroup:', elem, 'selected-elem')
1210
+ return null
1208
1211
  }
1209
1212
  }
1210
1213
 
@@ -1222,11 +1225,14 @@ const ungroupSelectedElement = () => {
1222
1225
  return
1223
1226
  }
1224
1227
  if (dataStorage.has(g, 'gsvg') || dataStorage.has(g, 'symbol')) {
1225
- // Is svg, so actually convert to group
1226
- convertToGroup(g)
1227
- return
1228
- }
1229
- if (g.tagName === 'use') {
1228
+ // Is svg (e.g. imported content), so convert to a real group first, then
1229
+ // fall through below to actually ungroup/flatten that new group - a
1230
+ // single Ungroup action on imported content should ungroup it, not just
1231
+ // silently swap its <use>/gsvg wrapper for a <g> that still needs a
1232
+ // second Ungroup to do anything.
1233
+ g = convertToGroup(g)
1234
+ if (!g) { return }
1235
+ } else if (g.tagName === 'use') {
1230
1236
  // Somehow doesn't have data set, so retrieve
1231
1237
  const href = getHref(g)
1232
1238
  if (!href || !href.startsWith('#')) {
@@ -1240,8 +1246,8 @@ const ungroupSelectedElement = () => {
1240
1246
  }
1241
1247
  dataStorage.put(g, 'symbol', symbol)
1242
1248
  dataStorage.put(g, 'ref', symbol)
1243
- convertToGroup(g)
1244
- return
1249
+ g = convertToGroup(g)
1250
+ if (!g) { return }
1245
1251
  }
1246
1252
  const parentsA = getParents(g.parentNode, 'a')
1247
1253
  if (parentsA?.length) {
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { NS } from './namespaces.js'
9
- import { transformPoint, getMatrix } from './math.js'
9
+ import { transformPoint, matrixMultiply, getTransformList, transformListToTransform } from './math.js'
10
10
  import {
11
11
  assignAttributes,
12
12
  getElement,
@@ -44,6 +44,40 @@ class TextActions {
44
44
  #lastY = null
45
45
  #allowDbl = false
46
46
 
47
+ /**
48
+ * Get the accumulated transformation matrix from the element up to the SVG content element.
49
+ * This includes transforms from all parent groups, fixing the issue where text cursor
50
+ * appears in the wrong position when editing text inside a transformed group.
51
+ * @param {Element} elem - The element to get the accumulated matrix for
52
+ * @returns {SVGMatrix|null} The accumulated transformation matrix, or null if none
53
+ * @private
54
+ */
55
+ #getAccumulatedMatrix = (elem) => {
56
+ const svgContent = svgCanvas.getSvgContent()
57
+ const matrices = []
58
+
59
+ let current = elem
60
+ while (current && current !== svgContent && current.nodeType === 1) {
61
+ const tlist = getTransformList(current)
62
+ if (tlist && tlist.numberOfItems > 0) {
63
+ const matrix = transformListToTransform(tlist).matrix
64
+ matrices.unshift(matrix) // Add to beginning to maintain correct order
65
+ }
66
+ current = current.parentNode
67
+ }
68
+
69
+ if (matrices.length === 0) {
70
+ return null
71
+ }
72
+
73
+ if (matrices.length === 1) {
74
+ return matrices[0]
75
+ }
76
+
77
+ // Multiply all matrices together
78
+ return matrixMultiply(...matrices)
79
+ }
80
+
47
81
  /**
48
82
  *
49
83
  * @param {Integer} index
@@ -526,11 +560,12 @@ class TextActions {
526
560
  const str = this.#curtext.textContent
527
561
  const len = str.length
528
562
 
529
- const xform = this.#curtext.getAttribute('transform')
530
-
531
563
  this.#textbb = utilsGetBBox(this.#curtext)
532
564
 
533
- this.#matrix = xform ? getMatrix(this.#curtext) : null
565
+ // Calculate accumulated transform matrix including all parent groups
566
+ // This fixes the issue where text cursor appears in wrong position
567
+ // when editing text inside a group with transforms
568
+ this.#matrix = this.#getAccumulatedMatrix(this.#curtext)
534
569
 
535
570
  this.#chardata = []
536
571
  this.#chardata.length = len