js-draw 0.3.2 → 0.4.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 (73) hide show
  1. package/CHANGELOG.md +9 -1
  2. package/README.md +1 -3
  3. package/dist/bundle.js +1 -1
  4. package/dist/src/Editor.d.ts +11 -0
  5. package/dist/src/Editor.js +104 -76
  6. package/dist/src/Pointer.d.ts +1 -1
  7. package/dist/src/Pointer.js +8 -3
  8. package/dist/src/Viewport.d.ts +1 -0
  9. package/dist/src/Viewport.js +14 -1
  10. package/dist/src/components/ImageComponent.d.ts +2 -2
  11. package/dist/src/language/assertions.d.ts +1 -0
  12. package/dist/src/language/assertions.js +5 -0
  13. package/dist/src/math/Mat33.d.ts +38 -2
  14. package/dist/src/math/Mat33.js +30 -1
  15. package/dist/src/math/Path.d.ts +1 -1
  16. package/dist/src/math/Path.js +10 -8
  17. package/dist/src/math/Vec3.d.ts +11 -1
  18. package/dist/src/math/Vec3.js +15 -0
  19. package/dist/src/math/rounding.d.ts +1 -0
  20. package/dist/src/math/rounding.js +13 -6
  21. package/dist/src/rendering/renderers/AbstractRenderer.js +2 -1
  22. package/dist/src/toolbar/HTMLToolbar.js +5 -4
  23. package/dist/src/toolbar/widgets/SelectionToolWidget.d.ts +1 -1
  24. package/dist/src/tools/PasteHandler.js +3 -1
  25. package/dist/src/tools/Pen.js +1 -1
  26. package/dist/src/tools/SelectionTool/Selection.d.ts +54 -0
  27. package/dist/src/tools/SelectionTool/Selection.js +337 -0
  28. package/dist/src/tools/SelectionTool/SelectionHandle.d.ts +35 -0
  29. package/dist/src/tools/SelectionTool/SelectionHandle.js +75 -0
  30. package/dist/src/tools/SelectionTool/SelectionTool.d.ts +31 -0
  31. package/dist/src/tools/SelectionTool/SelectionTool.js +276 -0
  32. package/dist/src/tools/SelectionTool/TransformMode.d.ts +34 -0
  33. package/dist/src/tools/SelectionTool/TransformMode.js +98 -0
  34. package/dist/src/tools/SelectionTool/types.d.ts +9 -0
  35. package/dist/src/tools/SelectionTool/types.js +11 -0
  36. package/dist/src/tools/ToolController.js +1 -1
  37. package/dist/src/tools/lib.d.ts +1 -1
  38. package/dist/src/tools/lib.js +1 -1
  39. package/dist/src/types.d.ts +1 -1
  40. package/package.json +1 -1
  41. package/src/Editor.css +1 -0
  42. package/src/Editor.ts +145 -108
  43. package/src/Pointer.ts +8 -3
  44. package/src/Viewport.ts +17 -2
  45. package/src/components/AbstractComponent.ts +2 -6
  46. package/src/components/ImageComponent.ts +2 -6
  47. package/src/components/Text.ts +2 -6
  48. package/src/language/assertions.ts +6 -0
  49. package/src/math/Mat33.test.ts +14 -0
  50. package/src/math/Mat33.ts +43 -2
  51. package/src/math/Path.toString.test.ts +12 -1
  52. package/src/math/Path.ts +11 -9
  53. package/src/math/Vec3.ts +22 -1
  54. package/src/math/rounding.test.ts +30 -5
  55. package/src/math/rounding.ts +16 -7
  56. package/src/rendering/renderers/AbstractRenderer.ts +3 -2
  57. package/src/toolbar/HTMLToolbar.ts +5 -4
  58. package/src/toolbar/widgets/SelectionToolWidget.ts +1 -1
  59. package/src/tools/PasteHandler.ts +4 -1
  60. package/src/tools/Pen.ts +1 -1
  61. package/src/tools/SelectionTool/Selection.ts +455 -0
  62. package/src/tools/SelectionTool/SelectionHandle.ts +99 -0
  63. package/src/tools/SelectionTool/SelectionTool.css +22 -0
  64. package/src/tools/{SelectionTool.test.ts → SelectionTool/SelectionTool.test.ts} +21 -21
  65. package/src/tools/SelectionTool/SelectionTool.ts +335 -0
  66. package/src/tools/SelectionTool/TransformMode.ts +114 -0
  67. package/src/tools/SelectionTool/types.ts +11 -0
  68. package/src/tools/ToolController.ts +1 -1
  69. package/src/tools/lib.ts +1 -1
  70. package/src/types.ts +1 -1
  71. package/dist/src/tools/SelectionTool.d.ts +0 -65
  72. package/dist/src/tools/SelectionTool.js +0 -647
  73. package/src/tools/SelectionTool.ts +0 -797
package/src/math/Vec3.ts CHANGED
@@ -95,6 +95,27 @@ export default class Vec3 {
95
95
  );
96
96
  }
97
97
 
98
+ /**
99
+ * If `other` is a `Vec3`, multiplies `this` component-wise by `other`. Otherwise,
100
+ * if `other is a `number`, returns the result of scalar multiplication.
101
+ *
102
+ * @example
103
+ * ```
104
+ * Vec3.of(1, 2, 3).scale(Vec3.of(2, 4, 6)); // → Vec3(2, 8, 18)
105
+ * ```
106
+ */
107
+ public scale(other: Vec3|number): Vec3 {
108
+ if (typeof other === 'number') {
109
+ return this.times(other);
110
+ }
111
+
112
+ return Vec3.of(
113
+ this.x * other.x,
114
+ this.y * other.y,
115
+ this.z * other.z,
116
+ );
117
+ }
118
+
98
119
  /**
99
120
  * Returns a vector orthogonal to this. If this is a Vec2, returns `this` rotated
100
121
  * 90 degrees counter-clockwise.
@@ -158,7 +179,7 @@ export default class Vec3 {
158
179
  );
159
180
  }
160
181
 
161
- public asArray(): number[] {
182
+ public asArray(): [ number, number, number ] {
162
183
  return [this.x, this.y, this.z];
163
184
  }
164
185
 
@@ -1,4 +1,4 @@
1
- import { toRoundedString, toStringOfSamePrecision } from './rounding';
1
+ import { cleanUpNumber, toRoundedString, toStringOfSamePrecision } from './rounding';
2
2
 
3
3
  describe('toRoundedString', () => {
4
4
  it('should round up numbers endings similar to .999999999999999', () => {
@@ -12,18 +12,28 @@ describe('toRoundedString', () => {
12
12
  expect(toRoundedString(10.999999998)).toBe('11');
13
13
  });
14
14
 
15
- // Handling this creates situations with potential error:
16
- //it('should round strings with multiple digits after the ending decimal points', () => {
17
- // expect(toRoundedString(292.2 - 292.8)).toBe('-0.6');
18
- //});
15
+ it('should round strings with multiple digits after the ending decimal points', () => {
16
+ expect(toRoundedString(292.2 - 292.8)).toBe('-.6');
17
+ expect(toRoundedString(4.06425600000023)).toBe('4.064256');
18
+ });
19
19
 
20
20
  it('should round down strings ending endings similar to .00000001', () => {
21
21
  expect(toRoundedString(10.00000001)).toBe('10');
22
+ expect(toRoundedString(-30.00000001)).toBe('-30');
23
+ expect(toRoundedString(-14.20000000000002)).toBe('-14.2');
24
+ });
25
+
26
+ it('should not round numbers insufficiently close to the next', () => {
27
+ expect(toRoundedString(-10.9999)).toBe('-10.9999');
28
+ expect(toRoundedString(-10.0001)).toBe('-10.0001');
29
+ expect(toRoundedString(-10.123499)).toBe('-10.123499');
30
+ expect(toRoundedString(0.00123499)).toBe('.00123499');
22
31
  });
23
32
  });
24
33
 
25
34
  it('toStringOfSamePrecision', () => {
26
35
  expect(toStringOfSamePrecision(1.23456, '1.12')).toBe('1.23');
36
+ expect(toStringOfSamePrecision(1.23456, '1.120')).toBe('1.235');
27
37
  expect(toStringOfSamePrecision(1.23456, '1.1')).toBe('1.2');
28
38
  expect(toStringOfSamePrecision(1.23456, '1.1', '5.32')).toBe('1.23');
29
39
  expect(toStringOfSamePrecision(-1.23456, '1.1', '5.32')).toBe('-1.23');
@@ -37,4 +47,19 @@ it('toStringOfSamePrecision', () => {
37
47
  expect(toStringOfSamePrecision(-0.09999999999999432, '291.3')).toBe('-.1');
38
48
  expect(toStringOfSamePrecision(-0.9999999999999432, '291.3')).toBe('-1');
39
49
  expect(toStringOfSamePrecision(9998.9, '.1', '-11')).toBe('9998.9');
50
+ expect(toStringOfSamePrecision(-14.20000000000002, '.000001', '-11')).toBe('-14.2');
51
+ });
52
+
53
+ it('cleanUpNumber', () => {
54
+ expect(cleanUpNumber('000.0000')).toBe('0');
55
+ expect(cleanUpNumber('-000.0000')).toBe('0');
56
+ expect(cleanUpNumber('0.0000')).toBe('0');
57
+ expect(cleanUpNumber('0.001')).toBe('.001');
58
+ expect(cleanUpNumber('-0.001')).toBe('-.001');
59
+ expect(cleanUpNumber('-0.000000001')).toBe('-.000000001');
60
+ expect(cleanUpNumber('-0.00000000100')).toBe('-.000000001');
61
+ expect(cleanUpNumber('1234')).toBe('1234');
62
+ expect(cleanUpNumber('1234.5')).toBe('1234.5');
63
+ expect(cleanUpNumber('1234.500')).toBe('1234.5');
64
+ expect(cleanUpNumber('1.1368683772161603e-13')).toBe('0');
40
65
  });
@@ -1,9 +1,17 @@
1
1
  // @packageDocumentation @internal
2
2
 
3
3
  // Clean up stringified numbers
4
- const cleanUpNumber = (text: string) => {
4
+ export const cleanUpNumber = (text: string) => {
5
5
  // Regular expression substitions can be somewhat expensive. Only do them
6
6
  // if necessary.
7
+
8
+ if (text.indexOf('e') > 0) {
9
+ // Round to zero.
10
+ if (text.match(/[eE][-]\d{2,}$/)) {
11
+ return '0';
12
+ }
13
+ }
14
+
7
15
  const lastChar = text.charAt(text.length - 1);
8
16
  if (lastChar === '0' || lastChar === '.') {
9
17
  // Remove trailing zeroes
@@ -12,10 +20,6 @@ const cleanUpNumber = (text: string) => {
12
20
 
13
21
  // Remove trailing period
14
22
  text = text.replace(/[.]$/, '');
15
-
16
- if (text === '-0') {
17
- return '0';
18
- }
19
23
  }
20
24
 
21
25
  const firstChar = text.charAt(0);
@@ -23,6 +27,11 @@ const cleanUpNumber = (text: string) => {
23
27
  // Remove unnecessary leading zeroes.
24
28
  text = text.replace(/^(0+)[.]/, '.');
25
29
  text = text.replace(/^-(0+)[.]/, '-.');
30
+ text = text.replace(/^(-?)0+$/, '$10');
31
+ }
32
+
33
+ if (text === '-0') {
34
+ return '0';
26
35
  }
27
36
 
28
37
  return text;
@@ -31,8 +40,8 @@ const cleanUpNumber = (text: string) => {
31
40
  export const toRoundedString = (num: number): string => {
32
41
  // Try to remove rounding errors. If the number ends in at least three/four zeroes
33
42
  // (or nines) just one or two digits, it's probably a rounding error.
34
- const fixRoundingUpExp = /^([-]?\d*\.\d{3,})0{4,}\d{1,2}$/;
35
- const hasRoundingDownExp = /^([-]?)(\d*)\.(\d{3,}9{4,})\d{1,2}$/;
43
+ const fixRoundingUpExp = /^([-]?\d*\.\d{3,})0{4,}\d{1,4}$/;
44
+ const hasRoundingDownExp = /^([-]?)(\d*)\.(\d{3,}9{4,})\d{1,4}$/;
36
45
 
37
46
  let text = num.toString(10);
38
47
  if (text.indexOf('.') === -1) {
@@ -122,8 +122,9 @@ export default abstract class AbstractRenderer {
122
122
  }
123
123
  }
124
124
 
125
- // Draw a rectangle. Boundary lines have width [lineWidth] and are filled with [lineFill]
126
- public drawRect(rect: Rect2, lineWidth: number, lineFill: RenderingStyle): void {
125
+ // Draw a rectangle. Boundary lines have width [lineWidth] and are filled with [lineFill].
126
+ // This is equivalent to `drawPath(Path.fromRect(...).toRenderable(...))`.
127
+ public drawRect(rect: Rect2, lineWidth: number, lineFill: RenderingStyle) {
127
128
  const path = Path.fromRect(rect, lineWidth);
128
129
  this.drawPath(path.toRenderable(lineFill));
129
130
  }
@@ -3,19 +3,20 @@ import { EditorEventType } from '../types';
3
3
 
4
4
  import { coloris, init as colorisInit } from '@melloware/coloris';
5
5
  import Color4 from '../Color4';
6
- import SelectionTool from '../tools/SelectionTool';
7
6
  import { defaultToolbarLocalization, ToolbarLocalization } from './localization';
8
7
  import { ActionButtonIcon } from './types';
9
8
  import { makeRedoIcon, makeUndoIcon } from './icons';
10
- import PanZoom from '../tools/PanZoom';
9
+ import SelectionTool from '../tools/SelectionTool/SelectionTool';
10
+ import PanZoomTool from '../tools/PanZoom';
11
11
  import TextTool from '../tools/TextTool';
12
+ import EraserTool from '../tools/Eraser';
13
+ import PenTool from '../tools/Pen';
12
14
  import PenToolWidget from './widgets/PenToolWidget';
13
15
  import EraserWidget from './widgets/EraserToolWidget';
14
16
  import SelectionToolWidget from './widgets/SelectionToolWidget';
15
17
  import TextToolWidget from './widgets/TextToolWidget';
16
18
  import HandToolWidget from './widgets/HandToolWidget';
17
19
  import BaseWidget from './widgets/BaseWidget';
18
- import { EraserTool, PenTool } from '../tools/lib';
19
20
 
20
21
  export const toolbarCSSPrefix = 'toolbar-';
21
22
 
@@ -200,7 +201,7 @@ export default class HTMLToolbar {
200
201
  this.addWidget(new TextToolWidget(this.editor, tool, this.localizationTable));
201
202
  }
202
203
 
203
- const panZoomTool = toolController.getMatchingTools(PanZoom)[0];
204
+ const panZoomTool = toolController.getMatchingTools(PanZoomTool)[0];
204
205
  if (panZoomTool) {
205
206
  this.addWidget(new HandToolWidget(this.editor, panZoomTool, this.localizationTable));
206
207
  }
@@ -1,5 +1,5 @@
1
1
  import Editor from '../../Editor';
2
- import SelectionTool from '../../tools/SelectionTool';
2
+ import SelectionTool from '../../tools/SelectionTool/SelectionTool';
3
3
  import { EditorEventType } from '../../types';
4
4
  import { makeDeleteSelectionIcon, makeDuplicateSelectionIcon, makeResizeViewportIcon, makeSelectionIcon } from '../icons';
5
5
  import { ToolbarLocalization } from '../localization';
@@ -11,11 +11,12 @@ import { PasteEvent } from '../types';
11
11
  import { Mat33, Rect2, Vec2 } from '../math/lib';
12
12
  import BaseTool from './BaseTool';
13
13
  import EditorImage from '../EditorImage';
14
- import SelectionTool from './SelectionTool';
14
+ import SelectionTool from './SelectionTool/SelectionTool';
15
15
  import TextTool from './TextTool';
16
16
  import Color4 from '../Color4';
17
17
  import { TextStyle } from '../components/Text';
18
18
  import ImageComponent from '../components/ImageComponent';
19
+ import Viewport from '../Viewport';
19
20
 
20
21
  // { @inheritDoc PasteHandler! }
21
22
  export default class PasteHandler extends BaseTool {
@@ -67,6 +68,8 @@ export default class PasteHandler extends BaseTool {
67
68
  }
68
69
  scaleRatio *= 2 / 3;
69
70
 
71
+ scaleRatio = Viewport.roundScaleRatio(scaleRatio);
72
+
70
73
  const transfm = Mat33.translation(
71
74
  visibleRect.center.minus(bbox.center)
72
75
  ).rightMul(
package/src/tools/Pen.ts CHANGED
@@ -171,7 +171,7 @@ export default class Pen extends BaseTool {
171
171
  }
172
172
 
173
173
  if (newThickness !== undefined) {
174
- newThickness = Math.min(Math.max(1, newThickness), 128);
174
+ newThickness = Math.min(Math.max(1, newThickness), 256);
175
175
  this.setThickness(newThickness);
176
176
  return true;
177
177
  }
@@ -0,0 +1,455 @@
1
+ /**
2
+ * @internal
3
+ * @packageDocumentation
4
+ */
5
+
6
+ import SerializableCommand from '../../commands/SerializableCommand';
7
+ import Editor from '../../Editor';
8
+ import { Mat33, Rect2 } from '../../math/lib';
9
+ import { Point2, Vec2 } from '../../math/Vec2';
10
+ import Pointer from '../../Pointer';
11
+ import SelectionHandle, { HandleShape, handleSize } from './SelectionHandle';
12
+ import { cssPrefix } from './SelectionTool';
13
+ import AbstractComponent from '../../components/AbstractComponent';
14
+ import { Mat33Array } from '../../math/Mat33';
15
+ import { EditorLocalization } from '../../localization';
16
+ import Viewport from '../../Viewport';
17
+ import Erase from '../../commands/Erase';
18
+ import Duplicate from '../../commands/Duplicate';
19
+ import Command from '../../commands/Command';
20
+ import { DragTransformer, ResizeTransformer, RotateTransformer } from './TransformMode';
21
+ import { ResizeMode } from './types';
22
+
23
+ const updateChunkSize = 100;
24
+
25
+ // @internal
26
+ export default class Selection {
27
+ private handles: SelectionHandle[];
28
+ private originalRegion: Rect2;
29
+
30
+ private transformers;
31
+
32
+ private transform: Mat33 = Mat33.identity;
33
+ private transformCommands: SerializableCommand[] = [];
34
+
35
+ private selectedElems: AbstractComponent[] = [];
36
+
37
+ private container: HTMLElement;
38
+ private backgroundElem: HTMLElement;
39
+
40
+ public constructor(startPoint: Point2, private editor: Editor) {
41
+ this.originalRegion = new Rect2(startPoint.x, startPoint.y, 0, 0);
42
+ this.transformers = {
43
+ drag: new DragTransformer(editor, this),
44
+ resize: new ResizeTransformer(editor, this),
45
+ rotate: new RotateTransformer(editor, this),
46
+ };
47
+
48
+ this.container = document.createElement('div');
49
+ this.backgroundElem = document.createElement('div');
50
+ this.backgroundElem.classList.add(`${cssPrefix}selection-background`);
51
+ this.container.appendChild(this.backgroundElem);
52
+
53
+ const resizeHorizontalHandle = new SelectionHandle(
54
+ HandleShape.Square,
55
+ Vec2.of(1, 0.5),
56
+ this,
57
+ (startPoint) => this.transformers.resize.onDragStart(startPoint, ResizeMode.HorizontalOnly),
58
+ (currentPoint) => this.transformers.resize.onDragUpdate(currentPoint),
59
+ () => this.transformers.resize.onDragEnd(),
60
+ );
61
+
62
+ const resizeVerticalHandle = new SelectionHandle(
63
+ HandleShape.Square,
64
+ Vec2.of(0.5, 1),
65
+ this,
66
+ (startPoint) => this.transformers.resize.onDragStart(startPoint, ResizeMode.VerticalOnly),
67
+ (currentPoint) => this.transformers.resize.onDragUpdate(currentPoint),
68
+ () => this.transformers.resize.onDragEnd(),
69
+ );
70
+
71
+ const resizeBothHandle = new SelectionHandle(
72
+ HandleShape.Square,
73
+ Vec2.of(1, 1),
74
+ this,
75
+ (startPoint) => this.transformers.resize.onDragStart(startPoint, ResizeMode.Both),
76
+ (currentPoint) => this.transformers.resize.onDragUpdate(currentPoint),
77
+ () => this.transformers.resize.onDragEnd(),
78
+ );
79
+
80
+ const rotationHandle = new SelectionHandle(
81
+ HandleShape.Circle,
82
+ Vec2.of(0.5, 0),
83
+ this,
84
+ (startPoint) => this.transformers.rotate.onDragStart(startPoint),
85
+ (currentPoint) => this.transformers.rotate.onDragUpdate(currentPoint),
86
+ () => this.transformers.rotate.onDragEnd(),
87
+ );
88
+
89
+ this.handles = [
90
+ resizeBothHandle,
91
+ resizeHorizontalHandle,
92
+ resizeVerticalHandle,
93
+ rotationHandle,
94
+ ];
95
+
96
+ for (const handle of this.handles) {
97
+ handle.addTo(this.backgroundElem);
98
+ }
99
+ }
100
+
101
+ public getTransform(): Mat33 {
102
+ return this.transform;
103
+ }
104
+
105
+ public get preTransformRegion(): Rect2 {
106
+ return this.originalRegion;
107
+ }
108
+
109
+ public get region(): Rect2 {
110
+ // TODO: This currently assumes that the region rotates about its center.
111
+ // This may not be true.
112
+ const rotationMatrix = Mat33.zRotation(this.regionRotation, this.originalRegion.center);
113
+ const scaleAndTranslateMat = this.transform.rightMul(rotationMatrix.inverse());
114
+ return this.originalRegion.transformedBoundingBox(scaleAndTranslateMat);
115
+ }
116
+
117
+ public get regionRotation(): number {
118
+ return this.transform.transformVec3(Vec2.unitX).angle();
119
+ }
120
+
121
+ public get preTransformedScreenRegion(): Rect2 {
122
+ const toScreen = (vec: Point2) => this.editor.viewport.canvasToScreen(vec);
123
+ return Rect2.fromCorners(
124
+ toScreen(this.preTransformRegion.topLeft),
125
+ toScreen(this.preTransformRegion.bottomRight)
126
+ );
127
+ }
128
+
129
+ public get preTransformedScreenRegionRotation(): number {
130
+ return this.editor.viewport.getRotationAngle();
131
+ }
132
+
133
+ public get screenRegion(): Rect2 {
134
+ const toScreen = this.editor.viewport.canvasToScreenTransform;
135
+ const scaleFactor = this.editor.viewport.getScaleFactor();
136
+
137
+ const screenCenter = toScreen.transformVec2(this.region.center);
138
+
139
+ return new Rect2(
140
+ screenCenter.x, screenCenter.y, scaleFactor * this.region.width, scaleFactor * this.region.height
141
+ ).translatedBy(this.region.size.times(-scaleFactor/2));
142
+ }
143
+
144
+ public get screenRegionRotation(): number {
145
+ return this.regionRotation + this.editor.viewport.getRotationAngle();
146
+ }
147
+
148
+ private computeTransformCommands(): SerializableCommand[] {
149
+ return this.selectedElems.map(elem => {
150
+ return elem.transformBy(this.transform);
151
+ });
152
+ }
153
+
154
+ // Applies, previews, but doesn't finalize the given transformation.
155
+ public setTransform(transform: Mat33, preview: boolean = true) {
156
+ this.transform = transform;
157
+
158
+ if (preview) {
159
+ this.previewTransformCmds();
160
+ this.scrollTo();
161
+ }
162
+ }
163
+
164
+ // Applies the current transformation to the selection
165
+ public finalizeTransform() {
166
+ this.transformCommands.forEach(cmd => {
167
+ cmd.unapply(this.editor);
168
+ });
169
+
170
+ const fullTransform = this.transform;
171
+ const currentTransfmCommands = this.computeTransformCommands();
172
+
173
+ // Reset for the next drag
174
+ this.transformCommands = [];
175
+ this.originalRegion = this.originalRegion.transformedBoundingBox(this.transform);
176
+ this.transform = Mat33.identity;
177
+
178
+ // Make the commands undo-able
179
+ this.editor.dispatch(new Selection.ApplyTransformationCommand(
180
+ this, currentTransfmCommands, fullTransform
181
+ ));
182
+ }
183
+
184
+ static {
185
+ SerializableCommand.register('selection-tool-transform', (json: any, editor) => {
186
+ // The selection box is lost when serializing/deserializing. No need to store box rotation
187
+ const fullTransform: Mat33 = new Mat33(...(json.transform as Mat33Array));
188
+ const commands = (json.commands as any[]).map(data => SerializableCommand.deserialize(data, editor));
189
+
190
+ return new this.ApplyTransformationCommand(null, commands, fullTransform);
191
+ });
192
+ }
193
+
194
+ private static ApplyTransformationCommand = class extends SerializableCommand {
195
+ public constructor(
196
+ private selection: Selection|null,
197
+ private currentTransfmCommands: SerializableCommand[],
198
+ private fullTransform: Mat33,
199
+ ) {
200
+ super('selection-tool-transform');
201
+ }
202
+
203
+ public async apply(editor: Editor) {
204
+ this.selection?.setTransform(this.fullTransform, false);
205
+ this.selection?.updateUI();
206
+ await editor.asyncApplyCommands(this.currentTransfmCommands, updateChunkSize);
207
+ this.selection?.setTransform(Mat33.identity, false);
208
+ this.selection?.recomputeRegion();
209
+ this.selection?.updateUI();
210
+ }
211
+
212
+ public async unapply(editor: Editor) {
213
+ this.selection?.setTransform(this.fullTransform.inverse(), false);
214
+ this.selection?.updateUI();
215
+
216
+ await editor.asyncUnapplyCommands(this.currentTransfmCommands, updateChunkSize);
217
+ this.selection?.setTransform(Mat33.identity);
218
+ this.selection?.recomputeRegion();
219
+ this.selection?.updateUI();
220
+ }
221
+
222
+ protected serializeToJSON() {
223
+ return {
224
+ commands: this.currentTransfmCommands.map(command => command.serialize()),
225
+ transform: this.fullTransform.toArray(),
226
+ };
227
+ }
228
+
229
+ public description(_editor: Editor, localizationTable: EditorLocalization) {
230
+ return localizationTable.transformedElements(this.currentTransfmCommands.length);
231
+ }
232
+ };
233
+
234
+ // Preview the effects of the current transformation on the selection
235
+ private previewTransformCmds() {
236
+ // Don't render what we're moving if it's likely to be slow.
237
+ if (this.selectedElems.length > updateChunkSize) {
238
+ this.updateUI();
239
+ return;
240
+ }
241
+
242
+ this.transformCommands.forEach(cmd => cmd.unapply(this.editor));
243
+ this.transformCommands = this.computeTransformCommands();
244
+ this.transformCommands.forEach(cmd => cmd.apply(this.editor));
245
+
246
+ this.updateUI();
247
+ }
248
+
249
+ // Find the objects corresponding to this in the document,
250
+ // select them.
251
+ // Returns false iff nothing was selected.
252
+ public resolveToObjects(): boolean {
253
+ let singleItemSelectionMode = false;
254
+ this.transform = Mat33.identity;
255
+
256
+ // Grow the rectangle, if necessary
257
+ if (this.region.w === 0 || this.region.h === 0) {
258
+ const padding = this.editor.viewport.visibleRect.maxDimension / 200;
259
+ this.originalRegion = Rect2.bboxOf(this.region.corners, padding);
260
+
261
+ // Only select one item if the rectangle was very small.
262
+ singleItemSelectionMode = true;
263
+ }
264
+
265
+ this.selectedElems = this.editor.image.getElementsIntersectingRegion(this.region).filter(elem => {
266
+ if (this.region.containsRect(elem.getBBox())) {
267
+ return true;
268
+ }
269
+
270
+ // Calculated bounding boxes can be slightly larger than their actual contents' bounding box.
271
+ // As such, test with more lines than just this' edges.
272
+ const testLines = [];
273
+ for (const subregion of this.region.divideIntoGrid(2, 2)) {
274
+ testLines.push(...subregion.getEdges());
275
+ }
276
+
277
+ return testLines.some(edge => elem.intersects(edge));
278
+ });
279
+
280
+ if (singleItemSelectionMode && this.selectedElems.length > 0) {
281
+ this.selectedElems = [ this.selectedElems[this.selectedElems.length - 1] ];
282
+ }
283
+
284
+ // Find the bounding box of all selected elements.
285
+ if (!this.recomputeRegion()) {
286
+ return false;
287
+ }
288
+ this.updateUI();
289
+
290
+ return true;
291
+ }
292
+
293
+ // Recompute this' region from the selected elements.
294
+ // Returns false if the selection is empty.
295
+ public recomputeRegion(): boolean {
296
+ const newRegion = this.selectedElems.reduce((
297
+ accumulator: Rect2|null, elem: AbstractComponent
298
+ ): Rect2 => {
299
+ return (accumulator ?? elem.getBBox()).union(elem.getBBox());
300
+ }, null);
301
+
302
+ if (!newRegion) {
303
+ this.cancelSelection();
304
+ return false;
305
+ }
306
+
307
+ this.originalRegion = newRegion;
308
+
309
+ const minSize = this.getMinCanvasSize();
310
+ if (this.originalRegion.w < minSize || this.originalRegion.h < minSize) {
311
+ // Add padding
312
+ const padding = minSize / 2;
313
+ this.originalRegion = Rect2.bboxOf(
314
+ this.originalRegion.corners, padding
315
+ );
316
+ }
317
+
318
+ return true;
319
+ }
320
+
321
+ public getMinCanvasSize(): number {
322
+ const canvasHandleSize = handleSize / this.editor.viewport.getScaleFactor();
323
+ return canvasHandleSize * 2;
324
+ }
325
+
326
+ public getSelectedItemCount() {
327
+ return this.selectedElems.length;
328
+ }
329
+
330
+ // @internal
331
+ public updateUI() {
332
+ // marginLeft, marginTop: Display relative to the top left of the selection overlay.
333
+ // left, top don't work for this.
334
+ this.backgroundElem.style.marginLeft = `${this.screenRegion.topLeft.x}px`;
335
+ this.backgroundElem.style.marginTop = `${this.screenRegion.topLeft.y}px`;
336
+
337
+ this.backgroundElem.style.width = `${this.screenRegion.width}px`;
338
+ this.backgroundElem.style.height = `${this.screenRegion.height}px`;
339
+
340
+ const rotationDeg = this.screenRegionRotation * 180 / Math.PI;
341
+ this.backgroundElem.style.transform = `rotate(${rotationDeg}deg)`;
342
+ this.backgroundElem.style.transformOrigin = 'center';
343
+
344
+ for (const handle of this.handles) {
345
+ handle.updatePosition();
346
+ }
347
+ }
348
+
349
+ private targetHandle: SelectionHandle|null = null;
350
+ private backgroundDragging: boolean = false;
351
+ public onDragStart(pointer: Pointer, target: EventTarget): boolean {
352
+ for (const handle of this.handles) {
353
+ if (handle.isTarget(target)) {
354
+ handle.handleDragStart(pointer);
355
+ this.targetHandle = handle;
356
+ return true;
357
+ }
358
+ }
359
+
360
+ if (this.backgroundElem === target) {
361
+ this.backgroundDragging = true;
362
+ this.transformers.drag.onDragStart(pointer.canvasPos);
363
+ return true;
364
+ }
365
+
366
+ return false;
367
+ }
368
+
369
+ public onDragUpdate(pointer: Pointer) {
370
+ if (this.backgroundDragging) {
371
+ this.transformers.drag.onDragUpdate(pointer.canvasPos);
372
+ }
373
+
374
+ if (this.targetHandle) {
375
+ this.targetHandle.handleDragUpdate(pointer);
376
+ }
377
+
378
+ this.updateUI();
379
+ }
380
+
381
+ public onDragEnd() {
382
+ if (this.backgroundDragging) {
383
+ this.transformers.drag.onDragEnd();
384
+ }
385
+ else if (this.targetHandle) {
386
+ this.targetHandle.handleDragEnd();
387
+ }
388
+
389
+ this.backgroundDragging = false;
390
+ this.targetHandle = null;
391
+ this.updateUI();
392
+ }
393
+
394
+ public onDragCancel() {
395
+ this.backgroundDragging = false;
396
+ this.targetHandle = null;
397
+ this.setTransform(Mat33.identity);
398
+ }
399
+
400
+ // Scroll the viewport to this. Does not zoom
401
+ public scrollTo() {
402
+ if (this.selectedElems.length === 0) {
403
+ return;
404
+ }
405
+
406
+ const screenRect = new Rect2(0, 0, this.editor.display.width, this.editor.display.height);
407
+ if (!screenRect.containsPoint(this.screenRegion.center)) {
408
+ const closestPoint = screenRect.getClosestPointOnBoundaryTo(this.screenRegion.center);
409
+ const screenDelta = this.screenRegion.center.minus(closestPoint);
410
+ const delta = this.editor.viewport.screenToCanvasTransform.transformVec3(screenDelta);
411
+ this.editor.dispatchNoAnnounce(
412
+ Viewport.transformBy(Mat33.translation(delta.times(-1))), false
413
+ );
414
+ }
415
+ }
416
+
417
+ public deleteSelectedObjects(): Command {
418
+ return new Erase(this.selectedElems);
419
+ }
420
+
421
+ public duplicateSelectedObjects(): Command {
422
+ return new Duplicate(this.selectedElems);
423
+ }
424
+
425
+ public addTo(elem: HTMLElement) {
426
+ if (this.container.parentElement) {
427
+ this.container.remove();
428
+ }
429
+
430
+ elem.appendChild(this.container);
431
+ }
432
+
433
+ public setToPoint(point: Point2) {
434
+ this.originalRegion = this.originalRegion.grownToPoint(point);
435
+ this.updateUI();
436
+ }
437
+
438
+ public cancelSelection() {
439
+ if (this.container.parentElement) {
440
+ this.container.remove();
441
+ }
442
+ this.originalRegion = Rect2.empty;
443
+ }
444
+
445
+ public setSelectedObjects(objects: AbstractComponent[], bbox: Rect2) {
446
+ this.originalRegion = bbox;
447
+ this.selectedElems = objects;
448
+ this.updateUI();
449
+ }
450
+
451
+ public getSelectedObjects(): AbstractComponent[] {
452
+ return this.selectedElems;
453
+ }
454
+ }
455
+