js-draw 0.11.3 → 0.12.0

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/bundle.js +1 -1
  3. package/dist/src/Color4.d.ts +1 -0
  4. package/dist/src/Color4.js +1 -0
  5. package/dist/src/Editor.js +4 -5
  6. package/dist/src/SVGLoader.js +43 -35
  7. package/dist/src/components/AbstractComponent.d.ts +1 -0
  8. package/dist/src/components/AbstractComponent.js +15 -0
  9. package/dist/src/toolbar/HTMLToolbar.d.ts +51 -0
  10. package/dist/src/toolbar/HTMLToolbar.js +63 -5
  11. package/dist/src/toolbar/IconProvider.d.ts +1 -1
  12. package/dist/src/toolbar/IconProvider.js +33 -6
  13. package/dist/src/toolbar/widgets/EraserToolWidget.d.ts +8 -1
  14. package/dist/src/toolbar/widgets/EraserToolWidget.js +45 -4
  15. package/dist/src/toolbar/widgets/PenToolWidget.js +2 -2
  16. package/dist/src/toolbar/widgets/SelectionToolWidget.js +12 -3
  17. package/dist/src/tools/Eraser.d.ts +10 -1
  18. package/dist/src/tools/Eraser.js +65 -13
  19. package/dist/src/tools/SelectionTool/Selection.d.ts +4 -1
  20. package/dist/src/tools/SelectionTool/Selection.js +64 -27
  21. package/dist/src/tools/SelectionTool/SelectionTool.js +3 -1
  22. package/dist/src/tools/TextTool.js +10 -6
  23. package/dist/src/types.d.ts +2 -2
  24. package/package.json +1 -1
  25. package/src/Color4.ts +1 -0
  26. package/src/Editor.ts +3 -4
  27. package/src/SVGLoader.ts +14 -14
  28. package/src/components/AbstractComponent.ts +19 -0
  29. package/src/toolbar/HTMLToolbar.ts +81 -5
  30. package/src/toolbar/IconProvider.ts +34 -6
  31. package/src/toolbar/widgets/EraserToolWidget.ts +64 -5
  32. package/src/toolbar/widgets/PenToolWidget.ts +2 -2
  33. package/src/toolbar/widgets/SelectionToolWidget.ts +2 -2
  34. package/src/tools/Eraser.test.ts +79 -0
  35. package/src/tools/Eraser.ts +81 -17
  36. package/src/tools/SelectionTool/Selection.ts +73 -23
  37. package/src/tools/SelectionTool/SelectionTool.test.ts +138 -21
  38. package/src/tools/SelectionTool/SelectionTool.ts +3 -1
  39. package/src/tools/TextTool.ts +14 -8
  40. package/src/types.ts +2 -2
@@ -82,20 +82,48 @@ export default class IconProvider {
82
82
  return icon;
83
83
  }
84
84
 
85
- public makeEraserIcon(): IconType {
85
+ public makeEraserIcon(eraserSize?: number): IconType {
86
86
  const icon = document.createElementNS(svgNamespace, 'svg');
87
+ eraserSize ??= 10;
88
+
89
+ const scaledSize = eraserSize / 4;
90
+ const eraserColor = '#ff70af';
87
91
 
88
- // Draw an eraser-like shape
92
+ // Draw an eraser-like shape. Created with Inkscape
89
93
  icon.innerHTML = `
90
94
  <g>
91
- <rect x=10 y=50 width=80 height=30 rx=10 fill='pink' />
95
+ <path
96
+ style="fill:${eraserColor}"
97
+ stroke="black"
98
+ transform="rotate(41.35)"
99
+ d="M 52.5 27
100
+ C 50 28.9 48.9 31.7 48.9 34.8
101
+ L 48.9 39.8
102
+ C 48.9 45.3 53.4 49.8 58.9 49.8
103
+ L 103.9 49.8
104
+ C 105.8 49.8 107.6 49.2 109.1 48.3
105
+ L 110.2 ${scaledSize + 49.5} L 159.7 ${scaledSize + 5}
106
+ L 157.7 ${-scaledSize + 5.2} L 112.4 ${49.5 - scaledSize}
107
+ C 113.4 43.5 113.9 41.7 113.9 39.8
108
+ L 113.9 34.8
109
+ C 113.9 29.3 109.4 24.8 103.9 24.8
110
+ L 58.9 24.8
111
+ C 56.5 24.8 54.3 25.7 52.5 27
112
+ z "
113
+ id="path438" />
114
+
92
115
  <rect
93
- x=10 y=10 width=80 height=50
116
+ stroke="#cc8077"
94
117
  ${iconColorFill}
95
- />
118
+ id="rect218"
119
+ width="65"
120
+ height="75"
121
+ x="48.9"
122
+ y="-38.7"
123
+ transform="rotate(41.35)" />
96
124
  </g>
97
125
  `;
98
- icon.setAttribute('viewBox', '0 0 100 100');
126
+ icon.setAttribute('viewBox', '0 0 120 120');
99
127
  return icon;
100
128
  }
101
129
 
@@ -1,26 +1,85 @@
1
1
  import Editor from '../../Editor';
2
2
  import Eraser from '../../tools/Eraser';
3
+ import { EditorEventType } from '../../types';
4
+ import { toolbarCSSPrefix } from '../HTMLToolbar';
3
5
  import { ToolbarLocalization } from '../localization';
4
6
  import BaseToolWidget from './BaseToolWidget';
7
+ import { SavedToolbuttonState } from './BaseWidget';
5
8
 
6
9
  export default class EraserToolWidget extends BaseToolWidget {
10
+ private thicknessInput: HTMLInputElement|null = null;
7
11
  public constructor(
8
12
  editor: Editor,
9
- tool: Eraser,
13
+ private tool: Eraser,
10
14
  localizationTable?: ToolbarLocalization
11
15
  ) {
12
16
  super(editor, tool, 'eraser-tool-widget', localizationTable);
17
+
18
+ this.editor.notifier.on(EditorEventType.ToolUpdated, toolEvt => {
19
+ if (toolEvt.kind === EditorEventType.ToolUpdated && toolEvt.tool === this.tool) {
20
+ this.updateInputs();
21
+ this.updateIcon();
22
+ }
23
+ });
13
24
  }
14
25
 
15
26
  protected getTitle(): string {
16
27
  return this.localizationTable.eraser;
17
28
  }
29
+
18
30
  protected createIcon(): Element {
19
- return this.editor.icons.makeEraserIcon();
31
+ return this.editor.icons.makeEraserIcon(this.tool.getThickness());
32
+ }
33
+
34
+ private updateInputs() {
35
+ if (this.thicknessInput) {
36
+ this.thicknessInput.value = `${this.tool.getThickness()}`;
37
+ }
38
+ }
39
+
40
+ private static nextThicknessInputId = 0;
41
+
42
+ protected fillDropdown(dropdown: HTMLElement): boolean {
43
+ const thicknessLabel = document.createElement('label');
44
+ this.thicknessInput = document.createElement('input');
45
+
46
+ this.thicknessInput.type = 'range';
47
+ this.thicknessInput.min = '4';
48
+ this.thicknessInput.max = '40';
49
+ this.thicknessInput.oninput = () => {
50
+ this.tool.setThickness(parseFloat(this.thicknessInput!.value));
51
+ };
52
+ this.thicknessInput.id = `${toolbarCSSPrefix}eraserThicknessInput${EraserToolWidget.nextThicknessInputId++}`;
53
+
54
+ thicknessLabel.innerText = this.localizationTable.thicknessLabel;
55
+ thicknessLabel.htmlFor = this.thicknessInput.id;
56
+
57
+ this.updateInputs();
58
+ dropdown.replaceChildren(thicknessLabel, this.thicknessInput);
59
+ return true;
60
+ }
61
+
62
+ public serializeState(): SavedToolbuttonState {
63
+ return {
64
+ ...super.serializeState(),
65
+
66
+ thickness: this.tool.getThickness(),
67
+ };
20
68
  }
21
69
 
22
- protected fillDropdown(_dropdown: HTMLElement): boolean {
23
- // No dropdown associated with the eraser
24
- return false;
70
+ public deserializeFrom(state: SavedToolbuttonState) {
71
+ super.deserializeFrom(state);
72
+
73
+ if (state.thickness) {
74
+ const parsedThickness = parseFloat(state.thickness);
75
+
76
+ if (typeof parsedThickness !== 'number' || !isFinite(parsedThickness)) {
77
+ throw new Error(
78
+ `Deserializing property ${parsedThickness} is not a number or is not finite.`
79
+ );
80
+ }
81
+
82
+ this.tool.setThickness(parsedThickness);
83
+ }
25
84
  }
26
85
  }
@@ -145,8 +145,8 @@ export default class PenToolWidget extends BaseToolWidget {
145
145
  const objectTypeSelect = document.createElement('select');
146
146
 
147
147
  // Give inputs IDs so we can label them with a <label for=...>Label text</label>
148
- thicknessInput.id = `${toolbarCSSPrefix}thicknessInput${PenToolWidget.idCounter++}`;
149
- objectTypeSelect.id = `${toolbarCSSPrefix}builderSelect${PenToolWidget.idCounter++}`;
148
+ thicknessInput.id = `${toolbarCSSPrefix}penThicknessInput${PenToolWidget.idCounter++}`;
149
+ objectTypeSelect.id = `${toolbarCSSPrefix}penBuilderSelect${PenToolWidget.idCounter++}`;
150
150
 
151
151
  thicknessLabel.innerText = this.localizationTable.thicknessLabel;
152
152
  thicknessLabel.setAttribute('for', thicknessInput.id);
@@ -35,9 +35,9 @@ export default class SelectionToolWidget extends BaseToolWidget {
35
35
  editor, 'duplicate-btn',
36
36
  () => editor.icons.makeDuplicateSelectionIcon(),
37
37
  this.localizationTable.duplicateSelection,
38
- () => {
38
+ async () => {
39
39
  const selection = this.tool.getSelection();
40
- this.editor.dispatch(selection!.duplicateSelectedObjects());
40
+ this.editor.dispatch(await selection!.duplicateSelectedObjects());
41
41
  },
42
42
  localization,
43
43
  );
@@ -0,0 +1,79 @@
1
+ import Editor from '../Editor';
2
+ import { Rect2, StrokeComponent } from '../lib';
3
+ import { Vec2 } from '../math/Vec2';
4
+ import createEditor from '../testing/createEditor';
5
+ import { InputEvtType } from '../types';
6
+ import Eraser from './Eraser';
7
+
8
+ const selectEraser = (editor: Editor) => {
9
+ const tools = editor.toolController;
10
+ const eraser = tools.getMatchingTools(Eraser)[0];
11
+ eraser.setEnabled(true);
12
+
13
+ return eraser;
14
+ };
15
+
16
+ const getAllStrokes = (editor: Editor) => {
17
+ return editor.image.getAllElements().filter(elem => elem instanceof StrokeComponent);
18
+ };
19
+
20
+ describe('Eraser', () => {
21
+ it('should erase object between locations of events', () => {
22
+ const editor = createEditor();
23
+
24
+ // Draw a line
25
+ editor.sendPenEvent(InputEvtType.PointerDownEvt, Vec2.of(0, 0));
26
+ jest.advanceTimersByTime(100);
27
+ editor.sendPenEvent(InputEvtType.PointerUpEvt, Vec2.of(200, 200));
28
+
29
+ // Should have drawn a line
30
+ const strokes = getAllStrokes(editor);
31
+ expect(strokes).toHaveLength(1);
32
+ expect(strokes[0].getBBox().area).toBeGreaterThanOrEqual(200 * 200);
33
+
34
+ selectEraser(editor);
35
+
36
+ // Erase the line.
37
+ editor.sendPenEvent(InputEvtType.PointerDownEvt, Vec2.of(200, 0));
38
+ jest.advanceTimersByTime(400);
39
+ editor.sendPenEvent(InputEvtType.PointerUpEvt, Vec2.of(0, 200));
40
+
41
+ // Should have erased the line
42
+ expect(getAllStrokes(editor)).toHaveLength(0);
43
+ });
44
+
45
+ it('should erase objects within eraser.thickness of an event when not zoomed', async () => {
46
+ const editor = createEditor();
47
+
48
+ await editor.loadFromSVG(`
49
+ <svg>
50
+ <path d='m0,0 l2,0 l0,2 l-2,0 z' fill="#ff0000"/>
51
+ <path d='m50,50 l2,0 l0,2 l-2,0 z' fill="#ff0000"/>
52
+ </svg>
53
+ `, true);
54
+
55
+ editor.viewport.resetTransform();
56
+
57
+ const allStrokes = getAllStrokes(editor);
58
+ expect(allStrokes).toHaveLength(2);
59
+ expect(allStrokes[0].getBBox()).objEq(new Rect2(0, 0, 2, 2));
60
+ expect(allStrokes[1].getBBox()).objEq(new Rect2(50, 50, 2, 2));
61
+
62
+ const eraser = selectEraser(editor);
63
+ eraser.setThickness(10);
64
+
65
+ // Erase the first stroke
66
+ editor.sendPenEvent(InputEvtType.PointerDownEvt, Vec2.of(3, 0));
67
+ jest.advanceTimersByTime(100);
68
+ editor.sendPenEvent(InputEvtType.PointerUpEvt, Vec2.of(3, 0));
69
+
70
+ expect(getAllStrokes(editor)).toHaveLength(1);
71
+
72
+ // Erase the remaining stroke
73
+ editor.sendPenEvent(InputEvtType.PointerDownEvt, Vec2.of(47, 47));
74
+ jest.advanceTimersByTime(100);
75
+ editor.sendPenEvent(InputEvtType.PointerUpEvt, Vec2.of(47, 47));
76
+
77
+ expect(getAllStrokes(editor)).toHaveLength(0);
78
+ });
79
+ });
@@ -1,15 +1,20 @@
1
- import { PointerEvt } from '../types';
1
+ import { EditorEventType, PointerEvt } from '../types';
2
2
  import BaseTool from './BaseTool';
3
3
  import Editor from '../Editor';
4
- import { Point2 } from '../math/Vec2';
4
+ import { Point2, Vec2 } from '../math/Vec2';
5
5
  import LineSegment2 from '../math/LineSegment2';
6
6
  import Erase from '../commands/Erase';
7
7
  import AbstractComponent from '../components/AbstractComponent';
8
8
  import { PointerDevice } from '../Pointer';
9
+ import Color4 from '../Color4';
10
+ import Rect2 from '../math/Rect2';
11
+ import RenderingStyle from '../rendering/RenderingStyle';
9
12
 
10
13
  export default class Eraser extends BaseTool {
11
- private lastPoint: Point2;
14
+ private lastPoint: Point2|null = null;
15
+ private isFirstEraseEvt: boolean = true;
12
16
  private toRemove: AbstractComponent[];
17
+ private thickness: number = 10;
13
18
 
14
19
  // Commands that each remove one element
15
20
  private partialCommands: Erase[] = [];
@@ -18,27 +23,48 @@ export default class Eraser extends BaseTool {
18
23
  super(editor.notifier, description);
19
24
  }
20
25
 
21
- public onPointerDown(event: PointerEvt): boolean {
22
- if (event.allPointers.length === 1 || event.current.device === PointerDevice.Eraser) {
23
- this.lastPoint = event.current.canvasPos;
24
- this.toRemove = [];
25
- return true;
26
- }
26
+ private clearPreview() {
27
+ this.editor.clearWetInk();
28
+ }
27
29
 
28
- return false;
30
+ private getSizeOnCanvas() {
31
+ return this.thickness / this.editor.viewport.getScaleFactor();
29
32
  }
30
33
 
31
- public onPointerMove(event: PointerEvt): void {
32
- const currentPoint = event.current.canvasPos;
33
- if (currentPoint.minus(this.lastPoint).magnitude() === 0) {
34
+ private drawPreviewAt(point: Point2) {
35
+ this.clearPreview();
36
+
37
+ const size = this.getSizeOnCanvas();
38
+
39
+ const renderer = this.editor.display.getWetInkRenderer();
40
+ const rect = this.getEraserRect(point);
41
+ const fill: RenderingStyle = {
42
+ fill: Color4.gray,
43
+ };
44
+ renderer.drawRect(rect, size / 4, fill);
45
+ }
46
+
47
+ private getEraserRect(centerPoint: Point2) {
48
+ const size = this.getSizeOnCanvas();
49
+ const halfSize = Vec2.of(size / 2, size / 2);
50
+ return Rect2.fromCorners(centerPoint.minus(halfSize), centerPoint.plus(halfSize));
51
+ }
52
+
53
+ private eraseTo(currentPoint: Point2) {
54
+ if (!this.isFirstEraseEvt && currentPoint.minus(this.lastPoint!).magnitude() === 0) {
34
55
  return;
35
56
  }
57
+ this.isFirstEraseEvt = false;
36
58
 
37
- const line = new LineSegment2(this.lastPoint, currentPoint);
38
- const region = line.bbox;
59
+ // Currently only objects within eraserRect or that intersect a straight line
60
+ // from the center of the current rect to the previous are erased. TODO: Erase
61
+ // all objects as if there were pointerMove events between the two points.
62
+ const eraserRect = this.getEraserRect(currentPoint);
63
+ const line = new LineSegment2(this.lastPoint!, currentPoint);
64
+ const region = Rect2.union(line.bbox, eraserRect);
39
65
 
40
66
  const intersectingElems = this.editor.image.getElementsIntersectingRegion(region).filter(component => {
41
- return component.intersects(line);
67
+ return component.intersects(line) || component.intersectsRect(eraserRect);
42
68
  });
43
69
 
44
70
  // Remove any intersecting elements.
@@ -50,10 +76,32 @@ export default class Eraser extends BaseTool {
50
76
 
51
77
  this.partialCommands.push(...newPartialCommands);
52
78
 
79
+ this.drawPreviewAt(currentPoint);
53
80
  this.lastPoint = currentPoint;
54
81
  }
55
82
 
56
- public onPointerUp(_event: PointerEvt): void {
83
+ public onPointerDown(event: PointerEvt): boolean {
84
+ if (event.allPointers.length === 1 || event.current.device === PointerDevice.Eraser) {
85
+ this.lastPoint = event.current.canvasPos;
86
+ this.toRemove = [];
87
+ this.isFirstEraseEvt = true;
88
+
89
+ this.drawPreviewAt(event.current.canvasPos);
90
+ return true;
91
+ }
92
+
93
+ return false;
94
+ }
95
+
96
+ public onPointerMove(event: PointerEvt): void {
97
+ const currentPoint = event.current.canvasPos;
98
+
99
+ this.eraseTo(currentPoint);
100
+ }
101
+
102
+ public onPointerUp(event: PointerEvt): void {
103
+ this.eraseTo(event.current.canvasPos);
104
+
57
105
  if (this.toRemove.length > 0) {
58
106
  // Undo commands for each individual component and unite into a single command.
59
107
  this.partialCommands.forEach(cmd => cmd.unapply(this.editor));
@@ -62,10 +110,26 @@ export default class Eraser extends BaseTool {
62
110
  const command = new Erase(this.toRemove);
63
111
  this.editor.dispatch(command); // dispatch: Makes undo-able.
64
112
  }
113
+
114
+ this.clearPreview();
65
115
  }
66
116
 
67
117
  public onGestureCancel(): void {
68
118
  this.partialCommands.forEach(cmd => cmd.unapply(this.editor));
69
119
  this.partialCommands = [];
120
+ this.clearPreview();
121
+ }
122
+
123
+ public getThickness() {
124
+ return this.thickness;
125
+ }
126
+
127
+ public setThickness(thickness: number) {
128
+ this.thickness = thickness;
129
+
130
+ this.editor.notifier.dispatch(EditorEventType.ToolUpdated, {
131
+ kind: EditorEventType.ToolUpdated,
132
+ tool: this,
133
+ });
70
134
  }
71
135
  }
@@ -100,6 +100,11 @@ export default class Selection {
100
100
  }
101
101
  }
102
102
 
103
+ // @internal Intended for unit tests
104
+ public getBackgroundElem(): HTMLElement {
105
+ return this.backgroundElem;
106
+ }
107
+
103
108
  public getTransform(): Mat33 {
104
109
  return this.transform;
105
110
  }
@@ -304,18 +309,7 @@ export default class Selection {
304
309
  }
305
310
 
306
311
  this.selectedElems = this.editor.image.getElementsIntersectingRegion(this.region).filter(elem => {
307
- if (this.region.containsRect(elem.getBBox())) {
308
- return true;
309
- }
310
-
311
- // Calculated bounding boxes can be slightly larger than their actual contents' bounding box.
312
- // As such, test with more lines than just this' edges.
313
- const testLines = [];
314
- for (const subregion of this.region.divideIntoGrid(2, 2)) {
315
- testLines.push(...subregion.getEdges());
316
- }
317
-
318
- return testLines.some(edge => elem.intersects(edge));
312
+ return elem.intersectsRect(this.region);
319
313
  });
320
314
 
321
315
  if (singleItemSelectionMode && this.selectedElems.length > 0) {
@@ -392,6 +386,9 @@ export default class Selection {
392
386
  }
393
387
  }
394
388
 
389
+ // Maps IDs to whether we removed the component from the image
390
+ private removedFromImage: Record<string, boolean> = {};
391
+
395
392
  // Add/remove the contents of this' seleciton from the editor.
396
393
  // Used to prevent previewed content from looking like duplicate content
397
394
  // while dragging.
@@ -400,7 +397,7 @@ export default class Selection {
400
397
  // the editor image is likely to be slow.)
401
398
  //
402
399
  // If removed from the image, selected elements are drawn as wet ink.
403
- private async addRemoveSelectionFromImage(inImage: boolean) {
400
+ private addRemoveSelectionFromImage(inImage: boolean) {
404
401
  // Don't hide elements if doing so will be slow.
405
402
  if (!inImage && this.selectedElems.length > maxPreviewElemCount) {
406
403
  return;
@@ -409,26 +406,46 @@ export default class Selection {
409
406
  for (const elem of this.selectedElems) {
410
407
  const parent = this.editor.image.findParent(elem);
411
408
 
412
- if (!inImage) {
413
- parent?.remove();
409
+ if (!inImage && parent) {
410
+ this.removedFromImage[elem.getId()] = true;
411
+ parent.remove();
414
412
  }
415
413
  // If we're making things visible and the selected object wasn't previously
416
414
  // visible,
417
- else if (!parent) {
415
+ else if (!parent && this.removedFromImage[elem.getId()]) {
418
416
  EditorImage.addElement(elem).apply(this.editor);
417
+
418
+ this.removedFromImage[elem.getId()] = false;
419
+ delete this.removedFromImage[elem.getId()];
419
420
  }
420
421
  }
421
422
 
422
- await this.editor.queueRerender();
423
- if (!inImage) {
424
- this.previewTransformCmds();
425
- }
423
+ // Don't await queueRerender. If we're running in a test, the re-render might never
424
+ // happen.
425
+ this.editor.queueRerender().then(() => {
426
+ if (!inImage) {
427
+ this.previewTransformCmds();
428
+ }
429
+ });
430
+ }
431
+
432
+ private removeDeletedElemsFromSelection() {
433
+ // Remove any deleted elements from the selection.
434
+ this.selectedElems = this.selectedElems.filter(elem => {
435
+ const hasParent = !!this.editor.image.findParent(elem);
436
+
437
+ // If we removed the element and haven't added it back yet, don't remove it
438
+ // from the selection.
439
+ const weRemoved = this.removedFromImage[elem.getId()];
440
+ return hasParent || weRemoved;
441
+ });
426
442
  }
427
443
 
428
444
  private targetHandle: SelectionHandle|null = null;
429
445
  private backgroundDragging: boolean = false;
430
446
  public onDragStart(pointer: Pointer, target: EventTarget): boolean {
431
- void this.addRemoveSelectionFromImage(false);
447
+ this.removeDeletedElemsFromSelection();
448
+ this.addRemoveSelectionFromImage(false);
432
449
 
433
450
  for (const handle of this.handles) {
434
451
  if (handle.isTarget(target)) {
@@ -503,11 +520,43 @@ export default class Selection {
503
520
  }
504
521
 
505
522
  public deleteSelectedObjects(): Command {
523
+ if (this.backgroundDragging || this.targetHandle) {
524
+ this.onDragEnd();
525
+ }
526
+
506
527
  return new Erase(this.selectedElems);
507
528
  }
508
529
 
509
- public duplicateSelectedObjects(): Command {
510
- return new Duplicate(this.selectedElems);
530
+ public async duplicateSelectedObjects(): Promise<Command> {
531
+ const wasTransforming = this.backgroundDragging || this.targetHandle;
532
+ let tmpApplyCommand: Command|null = null;
533
+
534
+ if (wasTransforming) {
535
+ // Don't update the selection's focus when redoing/undoing
536
+ const selectionToUpdate: Selection|null = null;
537
+ tmpApplyCommand = new Selection.ApplyTransformationCommand(
538
+ selectionToUpdate, this.selectedElems, this.transform
539
+ );
540
+
541
+ // Transform to ensure that the duplicates are in the correct location
542
+ await tmpApplyCommand.apply(this.editor);
543
+
544
+ // Show items again
545
+ this.addRemoveSelectionFromImage(true);
546
+ }
547
+
548
+ const duplicateCommand = new Duplicate(this.selectedElems);
549
+
550
+ if (wasTransforming) {
551
+ // Move the selected objects back to the correct location.
552
+ await tmpApplyCommand?.unapply(this.editor);
553
+ this.addRemoveSelectionFromImage(false);
554
+
555
+ this.previewTransformCmds();
556
+ this.updateUI();
557
+ }
558
+
559
+ return duplicateCommand;
511
560
  }
512
561
 
513
562
  public addTo(elem: HTMLElement) {
@@ -533,6 +582,7 @@ export default class Selection {
533
582
  }
534
583
 
535
584
  public setSelectedObjects(objects: AbstractComponent[], bbox: Rect2) {
585
+ this.addRemoveSelectionFromImage(true);
536
586
  this.originalRegion = bbox;
537
587
  this.selectedElems = objects.filter(object => object.isSelectable());
538
588
  this.updateUI();