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
@@ -28,6 +28,8 @@ import Display, { RenderingMode } from './rendering/Display';
28
28
  import Pointer from './Pointer';
29
29
  import Rect2 from './math/Rect2';
30
30
  import { EditorLocalization } from './localization';
31
+ declare type HTMLPointerEventType = 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel';
32
+ declare type HTMLPointerEventFilter = (eventName: HTMLPointerEventType, event: PointerEvent) => boolean;
31
33
  export interface EditorSettings {
32
34
  /** Defaults to `RenderingMode.CanvasRenderer` */
33
35
  renderingMode: RenderingMode;
@@ -140,8 +142,16 @@ export declare class Editor {
140
142
  */
141
143
  addToolbar(defaultLayout?: boolean): HTMLToolbar;
142
144
  private registerListeners;
145
+ private pointers;
146
+ private getPointerList;
147
+ /**
148
+ * Dispatches a `PointerEvent` to the editor. The target element for `evt` must have the same top left
149
+ * as the content of the editor.
150
+ */
151
+ handleHTMLPointerEvent(eventType: 'pointerdown' | 'pointermove' | 'pointerup' | 'pointercancel', evt: PointerEvent): boolean;
143
152
  private isEventSink;
144
153
  private handlePaste;
154
+ handlePointerEventsFrom(elem: HTMLElement, filter?: HTMLPointerEventFilter): void;
145
155
  /** Adds event listners for keypresses to `elem` and forwards those events to the editor. */
146
156
  handleKeyEventsFrom(elem: HTMLElement): void;
147
157
  /** `apply` a command. `command` will be announced for accessibility. */
@@ -183,6 +193,7 @@ export declare class Editor {
183
193
  remove: () => void;
184
194
  };
185
195
  addStyleSheet(content: string): HTMLStyleElement;
196
+ sendKeyboardEvent(eventType: InputEvtType.KeyPressEvent | InputEvtType.KeyUpEvent, key: string, ctrlKey?: boolean): void;
186
197
  sendPenEvent(eventType: InputEvtType.PointerDownEvt | InputEvtType.PointerMoveEvt | InputEvtType.PointerUpEvt, point: Point2, allPointers?: Pointer[]): void;
187
198
  toSVG(): SVGElement;
188
199
  loadFrom(loader: ImageLoader): Promise<void>;
@@ -70,6 +70,7 @@ export class Editor {
70
70
  var _a, _b, _c, _d;
71
71
  this.eventListenerTargets = [];
72
72
  this.previousAccessibilityAnnouncement = '';
73
+ this.pointers = {};
73
74
  this.announceUndoCallback = (command) => {
74
75
  this.announceForAccessibility(this.localization.undoAnnouncement(command.description(this, this.localization)));
75
76
  };
@@ -182,81 +183,7 @@ export class Editor {
182
183
  return toolbar;
183
184
  }
184
185
  registerListeners() {
185
- const pointers = {};
186
- const getPointerList = () => {
187
- const nowTime = (new Date()).getTime();
188
- const res = [];
189
- for (const id in pointers) {
190
- const maxUnupdatedTime = 2000; // Maximum time without a pointer update (ms)
191
- if (pointers[id] && (nowTime - pointers[id].timeStamp) < maxUnupdatedTime) {
192
- res.push(pointers[id]);
193
- }
194
- }
195
- return res;
196
- };
197
- // May be required to prevent text selection on iOS/Safari:
198
- // See https://stackoverflow.com/a/70992717/17055750
199
- this.renderingRegion.addEventListener('touchstart', evt => evt.preventDefault());
200
- this.renderingRegion.addEventListener('contextmenu', evt => {
201
- // Don't show a context menu
202
- evt.preventDefault();
203
- });
204
- this.renderingRegion.addEventListener('pointerdown', evt => {
205
- const pointer = Pointer.ofEvent(evt, true, this.viewport);
206
- pointers[pointer.id] = pointer;
207
- this.renderingRegion.setPointerCapture(pointer.id);
208
- const event = {
209
- kind: InputEvtType.PointerDownEvt,
210
- current: pointer,
211
- allPointers: getPointerList(),
212
- };
213
- this.toolController.dispatchInputEvent(event);
214
- return true;
215
- });
216
- this.renderingRegion.addEventListener('pointermove', evt => {
217
- var _a, _b;
218
- const pointer = Pointer.ofEvent(evt, (_b = (_a = pointers[evt.pointerId]) === null || _a === void 0 ? void 0 : _a.down) !== null && _b !== void 0 ? _b : false, this.viewport);
219
- if (pointer.down) {
220
- const prevData = pointers[pointer.id];
221
- if (prevData) {
222
- const distanceMoved = pointer.screenPos.minus(prevData.screenPos).magnitude();
223
- // If the pointer moved less than two pixels, don't send a new event.
224
- if (distanceMoved < 2) {
225
- return;
226
- }
227
- }
228
- pointers[pointer.id] = pointer;
229
- if (this.toolController.dispatchInputEvent({
230
- kind: InputEvtType.PointerMoveEvt,
231
- current: pointer,
232
- allPointers: getPointerList(),
233
- })) {
234
- evt.preventDefault();
235
- }
236
- }
237
- });
238
- const pointerEnd = (evt) => {
239
- const pointer = Pointer.ofEvent(evt, false, this.viewport);
240
- if (!pointers[pointer.id]) {
241
- return;
242
- }
243
- pointers[pointer.id] = pointer;
244
- this.renderingRegion.releasePointerCapture(pointer.id);
245
- if (this.toolController.dispatchInputEvent({
246
- kind: InputEvtType.PointerUpEvt,
247
- current: pointer,
248
- allPointers: getPointerList(),
249
- })) {
250
- evt.preventDefault();
251
- }
252
- delete pointers[pointer.id];
253
- };
254
- this.renderingRegion.addEventListener('pointerup', evt => {
255
- pointerEnd(evt);
256
- });
257
- this.renderingRegion.addEventListener('pointercancel', evt => {
258
- pointerEnd(evt);
259
- });
186
+ this.handlePointerEventsFrom(this.renderingRegion);
260
187
  this.handleKeyEventsFrom(this.renderingRegion);
261
188
  this.container.addEventListener('wheel', evt => {
262
189
  let delta = Vec3.of(evt.deltaX, evt.deltaY, evt.deltaZ);
@@ -282,7 +209,9 @@ export class Editor {
282
209
  if (evt.ctrlKey) {
283
210
  delta = Vec3.of(0, 0, evt.deltaY);
284
211
  }
285
- const pos = Vec2.of(evt.offsetX, evt.offsetY);
212
+ // Ensure that `pos` is relative to `this.container`
213
+ const bbox = this.container.getBoundingClientRect();
214
+ const pos = Vec2.of(evt.clientX, evt.clientY).minus(Vec2.of(bbox.left, bbox.top));
286
215
  if (this.toolController.dispatchInputEvent({
287
216
  kind: InputEvtType.WheelEvt,
288
217
  delta,
@@ -324,6 +253,78 @@ export class Editor {
324
253
  this.handlePaste(evt);
325
254
  });
326
255
  }
256
+ getPointerList() {
257
+ const nowTime = (new Date()).getTime();
258
+ const res = [];
259
+ for (const id in this.pointers) {
260
+ const maxUnupdatedTime = 2000; // Maximum time without a pointer update (ms)
261
+ if (this.pointers[id] && (nowTime - this.pointers[id].timeStamp) < maxUnupdatedTime) {
262
+ res.push(this.pointers[id]);
263
+ }
264
+ }
265
+ return res;
266
+ }
267
+ /**
268
+ * Dispatches a `PointerEvent` to the editor. The target element for `evt` must have the same top left
269
+ * as the content of the editor.
270
+ */
271
+ handleHTMLPointerEvent(eventType, evt) {
272
+ var _a, _b, _c;
273
+ const eventsRelativeTo = this.renderingRegion;
274
+ const eventTarget = (_a = evt.target) !== null && _a !== void 0 ? _a : this.renderingRegion;
275
+ if (eventType === 'pointerdown') {
276
+ const pointer = Pointer.ofEvent(evt, true, this.viewport, eventsRelativeTo);
277
+ this.pointers[pointer.id] = pointer;
278
+ eventTarget.setPointerCapture(pointer.id);
279
+ const event = {
280
+ kind: InputEvtType.PointerDownEvt,
281
+ current: pointer,
282
+ allPointers: this.getPointerList(),
283
+ };
284
+ this.toolController.dispatchInputEvent(event);
285
+ return true;
286
+ }
287
+ else if (eventType === 'pointermove') {
288
+ const pointer = Pointer.ofEvent(evt, (_c = (_b = this.pointers[evt.pointerId]) === null || _b === void 0 ? void 0 : _b.down) !== null && _c !== void 0 ? _c : false, this.viewport, eventsRelativeTo);
289
+ if (pointer.down) {
290
+ const prevData = this.pointers[pointer.id];
291
+ if (prevData) {
292
+ const distanceMoved = pointer.screenPos.minus(prevData.screenPos).magnitude();
293
+ // If the pointer moved less than two pixels, don't send a new event.
294
+ if (distanceMoved < 2) {
295
+ return false;
296
+ }
297
+ }
298
+ this.pointers[pointer.id] = pointer;
299
+ if (this.toolController.dispatchInputEvent({
300
+ kind: InputEvtType.PointerMoveEvt,
301
+ current: pointer,
302
+ allPointers: this.getPointerList(),
303
+ })) {
304
+ evt.preventDefault();
305
+ }
306
+ }
307
+ return true;
308
+ }
309
+ else if (eventType === 'pointercancel' || eventType === 'pointerup') {
310
+ const pointer = Pointer.ofEvent(evt, false, this.viewport, eventsRelativeTo);
311
+ if (!this.pointers[pointer.id]) {
312
+ return false;
313
+ }
314
+ this.pointers[pointer.id] = pointer;
315
+ eventTarget.releasePointerCapture(pointer.id);
316
+ if (this.toolController.dispatchInputEvent({
317
+ kind: InputEvtType.PointerUpEvt,
318
+ current: pointer,
319
+ allPointers: this.getPointerList(),
320
+ })) {
321
+ evt.preventDefault();
322
+ }
323
+ delete this.pointers[pointer.id];
324
+ return true;
325
+ }
326
+ return eventType;
327
+ }
327
328
  isEventSink(evtTarget) {
328
329
  let currentElem = evtTarget;
329
330
  while (currentElem !== null) {
@@ -411,6 +412,24 @@ export class Editor {
411
412
  }
412
413
  });
413
414
  }
415
+ handlePointerEventsFrom(elem, filter) {
416
+ // May be required to prevent text selection on iOS/Safari:
417
+ // See https://stackoverflow.com/a/70992717/17055750
418
+ elem.addEventListener('touchstart', evt => evt.preventDefault());
419
+ elem.addEventListener('contextmenu', evt => {
420
+ // Don't show a context menu
421
+ evt.preventDefault();
422
+ });
423
+ const eventNames = ['pointerdown', 'pointermove', 'pointerup', 'pointercancel'];
424
+ for (const eventName of eventNames) {
425
+ elem.addEventListener(eventName, evt => {
426
+ if (filter && !filter(eventName, evt)) {
427
+ return true;
428
+ }
429
+ return this.handleHTMLPointerEvent(eventName, evt);
430
+ });
431
+ }
432
+ }
414
433
  /** Adds event listners for keypresses to `elem` and forwards those events to the editor. */
415
434
  handleKeyEventsFrom(elem) {
416
435
  elem.addEventListener('keydown', evt => {
@@ -577,6 +596,15 @@ export class Editor {
577
596
  this.container.appendChild(styleSheet);
578
597
  return styleSheet;
579
598
  }
599
+ // Dispatch a keyboard event to the currently selected tool.
600
+ // Intended for unit testing
601
+ sendKeyboardEvent(eventType, key, ctrlKey = false) {
602
+ this.toolController.dispatchInputEvent({
603
+ kind: eventType,
604
+ key,
605
+ ctrlKey
606
+ });
607
+ }
580
608
  // Dispatch a pen event to the currently selected tool.
581
609
  // Intended primarially for unit tests.
582
610
  sendPenEvent(eventType, point, allPointers) {
@@ -18,6 +18,6 @@ export default class Pointer {
18
18
  readonly id: number;
19
19
  readonly timeStamp: number;
20
20
  private constructor();
21
- static ofEvent(evt: PointerEvent, isDown: boolean, viewport: Viewport): Pointer;
21
+ static ofEvent(evt: PointerEvent, isDown: boolean, viewport: Viewport, relativeTo?: HTMLElement): Pointer;
22
22
  static ofCanvasPoint(canvasPos: Point2, isDown: boolean, viewport: Viewport, id?: number, device?: PointerDevice, isPrimary?: boolean, pressure?: number | null): Pointer;
23
23
  }
@@ -31,10 +31,15 @@ export default class Pointer {
31
31
  this.id = id;
32
32
  this.timeStamp = timeStamp;
33
33
  }
34
- // Creates a Pointer from a DOM event.
35
- static ofEvent(evt, isDown, viewport) {
34
+ // Creates a Pointer from a DOM event. If `relativeTo` is given, (0, 0) in screen coordinates is
35
+ // considered the top left of `relativeTo`.
36
+ static ofEvent(evt, isDown, viewport, relativeTo) {
36
37
  var _a, _b;
37
- const screenPos = Vec2.of(evt.offsetX, evt.offsetY);
38
+ let screenPos = Vec2.of(evt.clientX, evt.clientY);
39
+ if (relativeTo) {
40
+ const bbox = relativeTo.getBoundingClientRect();
41
+ screenPos = screenPos.minus(Vec2.of(bbox.left, bbox.top));
42
+ }
38
43
  const pointerTypeToDevice = {
39
44
  'mouse': PointerDevice.PrimaryButtonMouse,
40
45
  'pen': PointerDevice.Pen,
@@ -29,6 +29,7 @@ export declare class Viewport {
29
29
  getRotationAngle(): number;
30
30
  static roundPoint<T extends Point2 | number>(point: T, tolerance: number): PointDataType<T>;
31
31
  roundPoint(point: Point2): Point2;
32
+ static roundScaleRatio(scaleRatio: number, roundAmount?: number): number;
32
33
  computeZoomToTransform(toMakeVisible: Rect2, allowZoomIn?: boolean, allowZoomOut?: boolean): Mat33;
33
34
  zoomTo(toMakeVisible: Rect2, allowZoomIn?: boolean, allowZoomOut?: boolean): Command;
34
35
  }
@@ -73,7 +73,8 @@ export class Viewport {
73
73
  getSizeOfPixelOnCanvas() {
74
74
  return 1 / this.getScaleFactor();
75
75
  }
76
- // Returns the angle of the canvas in radians
76
+ // Returns the angle of the canvas in radians.
77
+ // This is the angle by which the canvas is rotated relative to the screen.
77
78
  getRotationAngle() {
78
79
  return this.transform.transformVec3(Vec3.unitX).angle();
79
80
  }
@@ -94,6 +95,18 @@ export class Viewport {
94
95
  roundPoint(point) {
95
96
  return Viewport.roundPoint(point, 1 / this.getScaleFactor());
96
97
  }
98
+ // `roundAmount`: An integer >= 0, larger numbers cause less rounding. Smaller numbers cause more
99
+ // (as such `roundAmount = 0` does the most rounding).
100
+ static roundScaleRatio(scaleRatio, roundAmount = 1) {
101
+ if (Math.abs(scaleRatio) <= 1e-12) {
102
+ return 0;
103
+ }
104
+ // Represent as k 10ⁿ for some n, k ∈ ℤ.
105
+ const decimalComponent = Math.pow(10, Math.floor(Math.log10(Math.abs(scaleRatio))));
106
+ const roundAnountFactor = Math.pow(2, roundAmount);
107
+ scaleRatio = Math.round(scaleRatio / decimalComponent * roundAnountFactor) / roundAnountFactor * decimalComponent;
108
+ return scaleRatio;
109
+ }
97
110
  // Computes and returns an affine transformation that makes `toMakeVisible` visible and roughly centered on the screen.
98
111
  computeZoomToTransform(toMakeVisible, allowZoomIn = true, allowZoomOut = true) {
99
112
  let transform = Mat33.identity;
@@ -1,5 +1,5 @@
1
1
  import LineSegment2 from '../math/LineSegment2';
2
- import Mat33 from '../math/Mat33';
2
+ import Mat33, { Mat33Array } from '../math/Mat33';
3
3
  import Rect2 from '../math/Rect2';
4
4
  import AbstractRenderer, { RenderableImage } from '../rendering/renderers/AbstractRenderer';
5
5
  import AbstractComponent from './AbstractComponent';
@@ -18,7 +18,7 @@ export default class ImageComponent extends AbstractComponent {
18
18
  label: string | undefined;
19
19
  width: number;
20
20
  height: number;
21
- transform: number[];
21
+ transform: Mat33Array;
22
22
  };
23
23
  protected applyTransformation(affineTransfm: Mat33): void;
24
24
  description(localizationTable: ImageComponentLocalization): string;
@@ -0,0 +1 @@
1
+ export declare const assertUnreachable: (key: never) => never;
@@ -0,0 +1,5 @@
1
+ // Compile-time assertion that a branch of code is unreachable.
2
+ // See https://stackoverflow.com/a/39419171/17055750
3
+ export const assertUnreachable = (key) => {
4
+ throw new Error(`Should be unreachable. Key: ${key}.`);
5
+ };
@@ -1,5 +1,16 @@
1
1
  import { Point2, Vec2 } from './Vec2';
2
2
  import Vec3 from './Vec3';
3
+ export declare type Mat33Array = [
4
+ number,
5
+ number,
6
+ number,
7
+ number,
8
+ number,
9
+ number,
10
+ number,
11
+ number,
12
+ number
13
+ ];
3
14
  /**
4
15
  * Represents a three dimensional linear transformation or
5
16
  * a two-dimensional affine transformation. (An affine transformation scales/rotates/shears
@@ -68,11 +79,36 @@ export default class Mat33 {
68
79
  * ...
69
80
  * ```
70
81
  */
71
- toArray(): number[];
82
+ toArray(): Mat33Array;
83
+ /**
84
+ * @example
85
+ * ```
86
+ * new Mat33(
87
+ * 1, 2, 3,
88
+ * 4, 5, 6,
89
+ * 7, 8, 9,
90
+ * ).mapEntries(component => component - 1);
91
+ * // → ⎡ 0, 1, 2 ⎤
92
+ * // ⎢ 3, 4, 5 ⎥
93
+ * // ⎣ 6, 7, 8 ⎦
94
+ * ```
95
+ */
96
+ mapEntries(mapping: (component: number) => number): Mat33;
72
97
  /** Constructs a 3x3 translation matrix (for translating `Vec2`s) */
73
98
  static translation(amount: Vec2): Mat33;
74
99
  static zRotation(radians: number, center?: Point2): Mat33;
75
100
  static scaling2D(amount: number | Vec2, center?: Point2): Mat33;
76
- /** Converts a CSS-form `matrix(a, b, c, d, e, f)` to a Mat33. */
101
+ /** @see {@link !fromCSSMatrix} */
102
+ toCSSMatrix(): string;
103
+ /**
104
+ * Converts a CSS-form `matrix(a, b, c, d, e, f)` to a Mat33.
105
+ *
106
+ * Note that such a matrix has the form,
107
+ * ```
108
+ * ⎡ a c e ⎤
109
+ * ⎢ b d f ⎥
110
+ * ⎣ 0 0 1 ⎦
111
+ * ```
112
+ */
77
113
  static fromCSSMatrix(cssString: string): Mat33;
78
114
  }
@@ -183,6 +183,22 @@ export default class Mat33 {
183
183
  this.c1, this.c2, this.c3,
184
184
  ];
185
185
  }
186
+ /**
187
+ * @example
188
+ * ```
189
+ * new Mat33(
190
+ * 1, 2, 3,
191
+ * 4, 5, 6,
192
+ * 7, 8, 9,
193
+ * ).mapEntries(component => component - 1);
194
+ * // → ⎡ 0, 1, 2 ⎤
195
+ * // ⎢ 3, 4, 5 ⎥
196
+ * // ⎣ 6, 7, 8 ⎦
197
+ * ```
198
+ */
199
+ mapEntries(mapping) {
200
+ return new Mat33(mapping(this.a1), mapping(this.a2), mapping(this.a3), mapping(this.b1), mapping(this.b2), mapping(this.b3), mapping(this.c1), mapping(this.c2), mapping(this.c3));
201
+ }
186
202
  /** Constructs a 3x3 translation matrix (for translating `Vec2`s) */
187
203
  static translation(amount) {
188
204
  // When transforming Vec2s by a 3x3 matrix, we give the input
@@ -214,7 +230,20 @@ export default class Mat33 {
214
230
  // Translate such that [center] goes to (0, 0)
215
231
  return result.rightMul(Mat33.translation(center.times(-1)));
216
232
  }
217
- /** Converts a CSS-form `matrix(a, b, c, d, e, f)` to a Mat33. */
233
+ /** @see {@link !fromCSSMatrix} */
234
+ toCSSMatrix() {
235
+ return `matrix(${this.a1},${this.b1},${this.a2},${this.b2},${this.a3},${this.b3})`;
236
+ }
237
+ /**
238
+ * Converts a CSS-form `matrix(a, b, c, d, e, f)` to a Mat33.
239
+ *
240
+ * Note that such a matrix has the form,
241
+ * ```
242
+ * ⎡ a c e ⎤
243
+ * ⎢ b d f ⎥
244
+ * ⎣ 0 0 1 ⎦
245
+ * ```
246
+ */
218
247
  static fromCSSMatrix(cssString) {
219
248
  if (cssString === '' || cssString === 'none') {
220
249
  return Mat33.identity;
@@ -55,7 +55,7 @@ export default class Path {
55
55
  static fromRenderable(renderable: RenderablePathSpec): Path;
56
56
  toRenderable(fill: RenderingStyle): RenderablePathSpec;
57
57
  private cachedStringVersion;
58
- toString(): string;
58
+ toString(useNonAbsCommands?: boolean): string;
59
59
  serialize(): string;
60
60
  static toString(startPoint: Point2, parts: PathCommand[], onlyAbsCommands?: boolean): string;
61
61
  static fromString(pathString: string): Path;
@@ -282,13 +282,15 @@ export default class Path {
282
282
  path: this,
283
283
  };
284
284
  }
285
- toString() {
285
+ toString(useNonAbsCommands) {
286
286
  if (this.cachedStringVersion) {
287
287
  return this.cachedStringVersion;
288
288
  }
289
- // Hueristic: Try to determine whether converting absolute to relative commands is worth it.
290
- const makeRelativeCommands = Math.abs(this.bbox.topLeft.x) > 10 && Math.abs(this.bbox.topLeft.y) > 10;
291
- const result = Path.toString(this.startPoint, this.parts, !makeRelativeCommands);
289
+ if (useNonAbsCommands === undefined) {
290
+ // Hueristic: Try to determine whether converting absolute to relative commands is worth it.
291
+ useNonAbsCommands = Math.abs(this.bbox.topLeft.x) > 10 && Math.abs(this.bbox.topLeft.y) > 10;
292
+ }
293
+ const result = Path.toString(this.startPoint, this.parts, !useNonAbsCommands);
292
294
  this.cachedStringVersion = result;
293
295
  return result;
294
296
  }
@@ -307,10 +309,12 @@ export default class Path {
307
309
  const roundedPrevX = prevPoint ? toRoundedString(prevPoint.x) : '';
308
310
  const roundedPrevY = prevPoint ? toRoundedString(prevPoint.y) : '';
309
311
  for (const point of points) {
312
+ const xComponent = toRoundedString(point.x);
313
+ const yComponent = toRoundedString(point.y);
310
314
  // Relative commands are often shorter as strings than absolute commands.
311
315
  if (!makeAbsCommand) {
312
- const xComponentRelative = toStringOfSamePrecision(point.x - prevPoint.x, roundedPrevX, roundedPrevY);
313
- const yComponentRelative = toStringOfSamePrecision(point.y - prevPoint.y, roundedPrevX, roundedPrevY);
316
+ const xComponentRelative = toStringOfSamePrecision(point.x - prevPoint.x, xComponent, roundedPrevX, roundedPrevY);
317
+ const yComponentRelative = toStringOfSamePrecision(point.y - prevPoint.y, yComponent, roundedPrevX, roundedPrevY);
314
318
  // No need for an additional separator if it starts with a '-'
315
319
  if (yComponentRelative.charAt(0) === '-') {
316
320
  relativeCommandParts.push(`${xComponentRelative}${yComponentRelative}`);
@@ -320,8 +324,6 @@ export default class Path {
320
324
  }
321
325
  }
322
326
  else {
323
- const xComponent = toRoundedString(point.x);
324
- const yComponent = toRoundedString(point.y);
325
327
  absoluteCommandParts.push(`${xComponent},${yComponent}`);
326
328
  }
327
329
  }
@@ -38,6 +38,16 @@ export default class Vec3 {
38
38
  minus(v: Vec3): Vec3;
39
39
  dot(other: Vec3): number;
40
40
  cross(other: Vec3): Vec3;
41
+ /**
42
+ * If `other` is a `Vec3`, multiplies `this` component-wise by `other`. Otherwise,
43
+ * if `other is a `number`, returns the result of scalar multiplication.
44
+ *
45
+ * @example
46
+ * ```
47
+ * Vec3.of(1, 2, 3).scale(Vec3.of(2, 4, 6)); // → Vec3(2, 8, 18)
48
+ * ```
49
+ */
50
+ scale(other: Vec3 | number): Vec3;
41
51
  /**
42
52
  * Returns a vector orthogonal to this. If this is a Vec2, returns `this` rotated
43
53
  * 90 degrees counter-clockwise.
@@ -73,7 +83,7 @@ export default class Vec3 {
73
83
  * ```
74
84
  */
75
85
  map(fn: (component: number, index: number) => number): Vec3;
76
- asArray(): number[];
86
+ asArray(): [number, number, number];
77
87
  /**
78
88
  * [fuzz] The maximum difference between two components for this and [other]
79
89
  * to be considered equal.
@@ -76,6 +76,21 @@ export default class Vec3 {
76
76
  // | x2 y2 z2|
77
77
  return Vec3.of(this.y * other.z - other.y * this.z, other.x * this.z - this.x * other.z, this.x * other.y - other.x * this.y);
78
78
  }
79
+ /**
80
+ * If `other` is a `Vec3`, multiplies `this` component-wise by `other`. Otherwise,
81
+ * if `other is a `number`, returns the result of scalar multiplication.
82
+ *
83
+ * @example
84
+ * ```
85
+ * Vec3.of(1, 2, 3).scale(Vec3.of(2, 4, 6)); // → Vec3(2, 8, 18)
86
+ * ```
87
+ */
88
+ scale(other) {
89
+ if (typeof other === 'number') {
90
+ return this.times(other);
91
+ }
92
+ return Vec3.of(this.x * other.x, this.y * other.y, this.z * other.z);
93
+ }
79
94
  /**
80
95
  * Returns a vector orthogonal to this. If this is a Vec2, returns `this` rotated
81
96
  * 90 degrees counter-clockwise.
@@ -1,3 +1,4 @@
1
+ export declare const cleanUpNumber: (text: string) => string;
1
2
  export declare const toRoundedString: (num: number) => string;
2
3
  export declare const getLenAfterDecimal: (numberAsString: string) => number;
3
4
  export declare const toStringOfSamePrecision: (num: number, ...references: string[]) => string;
@@ -1,8 +1,14 @@
1
1
  // @packageDocumentation @internal
2
2
  // Clean up stringified numbers
3
- const cleanUpNumber = (text) => {
3
+ export const cleanUpNumber = (text) => {
4
4
  // Regular expression substitions can be somewhat expensive. Only do them
5
5
  // if necessary.
6
+ if (text.indexOf('e') > 0) {
7
+ // Round to zero.
8
+ if (text.match(/[eE][-]\d{2,}$/)) {
9
+ return '0';
10
+ }
11
+ }
6
12
  const lastChar = text.charAt(text.length - 1);
7
13
  if (lastChar === '0' || lastChar === '.') {
8
14
  // Remove trailing zeroes
@@ -10,23 +16,24 @@ const cleanUpNumber = (text) => {
10
16
  text = text.replace(/[.]0+$/, '.');
11
17
  // Remove trailing period
12
18
  text = text.replace(/[.]$/, '');
13
- if (text === '-0') {
14
- return '0';
15
- }
16
19
  }
17
20
  const firstChar = text.charAt(0);
18
21
  if (firstChar === '0' || firstChar === '-') {
19
22
  // Remove unnecessary leading zeroes.
20
23
  text = text.replace(/^(0+)[.]/, '.');
21
24
  text = text.replace(/^-(0+)[.]/, '-.');
25
+ text = text.replace(/^(-?)0+$/, '$10');
26
+ }
27
+ if (text === '-0') {
28
+ return '0';
22
29
  }
23
30
  return text;
24
31
  };
25
32
  export const toRoundedString = (num) => {
26
33
  // Try to remove rounding errors. If the number ends in at least three/four zeroes
27
34
  // (or nines) just one or two digits, it's probably a rounding error.
28
- const fixRoundingUpExp = /^([-]?\d*\.\d{3,})0{4,}\d{1,2}$/;
29
- const hasRoundingDownExp = /^([-]?)(\d*)\.(\d{3,}9{4,})\d{1,2}$/;
35
+ const fixRoundingUpExp = /^([-]?\d*\.\d{3,})0{4,}\d{1,4}$/;
36
+ const hasRoundingDownExp = /^([-]?)(\d*)\.(\d{3,}9{4,})\d{1,4}$/;
30
37
  let text = num.toString(10);
31
38
  if (text.indexOf('.') === -1) {
32
39
  return text;
@@ -64,7 +64,8 @@ export default class AbstractRenderer {
64
64
  this.currentPaths.push(path);
65
65
  }
66
66
  }
67
- // Draw a rectangle. Boundary lines have width [lineWidth] and are filled with [lineFill]
67
+ // Draw a rectangle. Boundary lines have width [lineWidth] and are filled with [lineFill].
68
+ // This is equivalent to `drawPath(Path.fromRect(...).toRenderable(...))`.
68
69
  drawRect(rect, lineWidth, lineFill) {
69
70
  const path = Path.fromRect(rect, lineWidth);
70
71
  this.drawPath(path.toRenderable(lineFill));
@@ -1,17 +1,18 @@
1
1
  import { EditorEventType } from '../types';
2
2
  import { coloris, init as colorisInit } from '@melloware/coloris';
3
3
  import Color4 from '../Color4';
4
- import SelectionTool from '../tools/SelectionTool';
5
4
  import { defaultToolbarLocalization } from './localization';
6
5
  import { makeRedoIcon, makeUndoIcon } from './icons';
7
- import PanZoom from '../tools/PanZoom';
6
+ import SelectionTool from '../tools/SelectionTool/SelectionTool';
7
+ import PanZoomTool from '../tools/PanZoom';
8
8
  import TextTool from '../tools/TextTool';
9
+ import EraserTool from '../tools/Eraser';
10
+ import PenTool from '../tools/Pen';
9
11
  import PenToolWidget from './widgets/PenToolWidget';
10
12
  import EraserWidget from './widgets/EraserToolWidget';
11
13
  import SelectionToolWidget from './widgets/SelectionToolWidget';
12
14
  import TextToolWidget from './widgets/TextToolWidget';
13
15
  import HandToolWidget from './widgets/HandToolWidget';
14
- import { EraserTool, PenTool } from '../tools/lib';
15
16
  export const toolbarCSSPrefix = 'toolbar-';
16
17
  export default class HTMLToolbar {
17
18
  /** @internal */
@@ -158,7 +159,7 @@ export default class HTMLToolbar {
158
159
  for (const tool of toolController.getMatchingTools(TextTool)) {
159
160
  this.addWidget(new TextToolWidget(this.editor, tool, this.localizationTable));
160
161
  }
161
- const panZoomTool = toolController.getMatchingTools(PanZoom)[0];
162
+ const panZoomTool = toolController.getMatchingTools(PanZoomTool)[0];
162
163
  if (panZoomTool) {
163
164
  this.addWidget(new HandToolWidget(this.editor, panZoomTool, this.localizationTable));
164
165
  }
@@ -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 { ToolbarLocalization } from '../localization';
4
4
  import BaseToolWidget from './BaseToolWidget';
5
5
  export default class SelectionToolWidget extends BaseToolWidget {