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/Editor.ts CHANGED
@@ -1,18 +1,18 @@
1
1
  /**
2
2
  * The main entrypoint for the full editor.
3
- *
3
+ *
4
4
  * @example
5
5
  * To create an editor with a toolbar,
6
6
  * ```
7
7
  * const editor = new Editor(document.body);
8
- *
8
+ *
9
9
  * const toolbar = editor.addToolbar();
10
10
  * toolbar.addActionButton('Save', () => {
11
11
  * const saveData = editor.toSVG().outerHTML;
12
12
  * // Do something with saveData...
13
13
  * });
14
14
  * ```
15
- *
15
+ *
16
16
  * @packageDocumentation
17
17
  */
18
18
 
@@ -38,6 +38,9 @@ import Rect2 from './math/Rect2';
38
38
  import { EditorLocalization } from './localization';
39
39
  import getLocalizationTable from './localizations/getLocalizationTable';
40
40
 
41
+ type HTMLPointerEventType = 'pointerdown'|'pointermove'|'pointerup'|'pointercancel';
42
+ type HTMLPointerEventFilter = (eventName: HTMLPointerEventType, event: PointerEvent)=>boolean;
43
+
41
44
  export interface EditorSettings {
42
45
  /** Defaults to `RenderingMode.CanvasRenderer` */
43
46
  renderingMode: RenderingMode,
@@ -67,14 +70,14 @@ export class Editor {
67
70
 
68
71
  /**
69
72
  * Handles undo/redo.
70
- *
73
+ *
71
74
  * @example
72
75
  * ```
73
76
  * const editor = new Editor(document.body);
74
- *
77
+ *
75
78
  * // Do something undoable.
76
79
  * // ...
77
- *
80
+ *
78
81
  * // Undo the last action
79
82
  * editor.history.undo();
80
83
  * ```
@@ -83,17 +86,17 @@ export class Editor {
83
86
 
84
87
  /**
85
88
  * Data structure for adding/removing/querying objects in the image.
86
- *
89
+ *
87
90
  * @example
88
91
  * ```
89
92
  * const editor = new Editor(document.body);
90
- *
93
+ *
91
94
  * // Create a path.
92
95
  * const stroke = new Stroke([
93
96
  * Path.fromString('M0,0 L30,30 z').toRenderable({ fill: Color4.black }),
94
97
  * ]);
95
98
  * const addElementCommand = editor.image.addElement(stroke);
96
- *
99
+ *
97
100
  * // Add the stroke to the editor
98
101
  * editor.dispatch(addElementCommand);
99
102
  * ```
@@ -126,14 +129,14 @@ export class Editor {
126
129
  * @example
127
130
  * ```
128
131
  * const container = document.body;
129
- *
132
+ *
130
133
  * // Create an editor
131
134
  * const editor = new Editor(container, {
132
135
  * // 2e-10 and 1e12 are the default values for minimum/maximum zoom.
133
136
  * minZoom: 2e-10,
134
137
  * maxZoom: 1e12,
135
138
  * });
136
- *
139
+ *
137
140
  * // Add the default toolbar
138
141
  * const toolbar = editor.addToolbar();
139
142
  * toolbar.addActionButton({
@@ -224,7 +227,7 @@ export class Editor {
224
227
  if (oldZoom <= this.settings.maxZoom && oldZoom >= this.settings.minZoom) {
225
228
  resetTransform = evt.oldTransform;
226
229
  }
227
-
230
+
228
231
  this.viewport.resetTransform(resetTransform);
229
232
  }
230
233
  }
@@ -233,7 +236,7 @@ export class Editor {
233
236
 
234
237
  /**
235
238
  * @returns a reference to the editor's container.
236
- *
239
+ *
237
240
  * @example
238
241
  * ```
239
242
  * editor.getRootElement().style.height = '500px';
@@ -285,96 +288,7 @@ export class Editor {
285
288
  }
286
289
 
287
290
  private registerListeners() {
288
- const pointers: Record<number, Pointer> = {};
289
- const getPointerList = () => {
290
- const nowTime = (new Date()).getTime();
291
-
292
- const res: Pointer[] = [];
293
- for (const id in pointers) {
294
- const maxUnupdatedTime = 2000; // Maximum time without a pointer update (ms)
295
- if (pointers[id] && (nowTime - pointers[id].timeStamp) < maxUnupdatedTime) {
296
- res.push(pointers[id]);
297
- }
298
- }
299
- return res;
300
- };
301
-
302
- // May be required to prevent text selection on iOS/Safari:
303
- // See https://stackoverflow.com/a/70992717/17055750
304
- this.renderingRegion.addEventListener('touchstart', evt => evt.preventDefault());
305
- this.renderingRegion.addEventListener('contextmenu', evt => {
306
- // Don't show a context menu
307
- evt.preventDefault();
308
- });
309
-
310
- this.renderingRegion.addEventListener('pointerdown', evt => {
311
- const pointer = Pointer.ofEvent(evt, true, this.viewport);
312
- pointers[pointer.id] = pointer;
313
-
314
- this.renderingRegion.setPointerCapture(pointer.id);
315
- const event: PointerEvt = {
316
- kind: InputEvtType.PointerDownEvt,
317
- current: pointer,
318
- allPointers: getPointerList(),
319
- };
320
- this.toolController.dispatchInputEvent(event);
321
-
322
- return true;
323
- });
324
-
325
- this.renderingRegion.addEventListener('pointermove', evt => {
326
- const pointer = Pointer.ofEvent(
327
- evt, pointers[evt.pointerId]?.down ?? false, this.viewport
328
- );
329
- if (pointer.down) {
330
- const prevData = pointers[pointer.id];
331
-
332
- if (prevData) {
333
- const distanceMoved = pointer.screenPos.minus(prevData.screenPos).magnitude();
334
-
335
- // If the pointer moved less than two pixels, don't send a new event.
336
- if (distanceMoved < 2) {
337
- return;
338
- }
339
- }
340
-
341
- pointers[pointer.id] = pointer;
342
- if (this.toolController.dispatchInputEvent({
343
- kind: InputEvtType.PointerMoveEvt,
344
- current: pointer,
345
- allPointers: getPointerList(),
346
- })) {
347
- evt.preventDefault();
348
- }
349
- }
350
- });
351
-
352
- const pointerEnd = (evt: PointerEvent) => {
353
- const pointer = Pointer.ofEvent(evt, false, this.viewport);
354
- if (!pointers[pointer.id]) {
355
- return;
356
- }
357
-
358
- pointers[pointer.id] = pointer;
359
- this.renderingRegion.releasePointerCapture(pointer.id);
360
- if (this.toolController.dispatchInputEvent({
361
- kind: InputEvtType.PointerUpEvt,
362
- current: pointer,
363
- allPointers: getPointerList(),
364
- })) {
365
- evt.preventDefault();
366
- }
367
- delete pointers[pointer.id];
368
- };
369
-
370
- this.renderingRegion.addEventListener('pointerup', evt => {
371
- pointerEnd(evt);
372
- });
373
-
374
- this.renderingRegion.addEventListener('pointercancel', evt => {
375
- pointerEnd(evt);
376
- });
377
-
291
+ this.handlePointerEventsFrom(this.renderingRegion);
378
292
  this.handleKeyEventsFrom(this.renderingRegion);
379
293
 
380
294
  this.container.addEventListener('wheel', evt => {
@@ -404,7 +318,10 @@ export class Editor {
404
318
  delta = Vec3.of(0, 0, evt.deltaY);
405
319
  }
406
320
 
407
- const pos = Vec2.of(evt.offsetX, evt.offsetY);
321
+ // Ensure that `pos` is relative to `this.container`
322
+ const bbox = this.container.getBoundingClientRect();
323
+ const pos = Vec2.of(evt.clientX, evt.clientY).minus(Vec2.of(bbox.left, bbox.top));
324
+
408
325
  if (this.toolController.dispatchInputEvent({
409
326
  kind: InputEvtType.WheelEvt,
410
327
  delta,
@@ -459,6 +376,91 @@ export class Editor {
459
376
  });
460
377
  }
461
378
 
379
+ private pointers: Record<number, Pointer> = {};
380
+ private getPointerList() {
381
+ const nowTime = (new Date()).getTime();
382
+
383
+ const res: Pointer[] = [];
384
+ for (const id in this.pointers) {
385
+ const maxUnupdatedTime = 2000; // Maximum time without a pointer update (ms)
386
+ if (this.pointers[id] && (nowTime - this.pointers[id].timeStamp) < maxUnupdatedTime) {
387
+ res.push(this.pointers[id]);
388
+ }
389
+ }
390
+ return res;
391
+ }
392
+
393
+ /**
394
+ * Dispatches a `PointerEvent` to the editor. The target element for `evt` must have the same top left
395
+ * as the content of the editor.
396
+ */
397
+ public handleHTMLPointerEvent(eventType: 'pointerdown'|'pointermove'|'pointerup'|'pointercancel', evt: PointerEvent): boolean {
398
+ const eventsRelativeTo = this.renderingRegion;
399
+ const eventTarget = (evt.target as HTMLElement|null) ?? this.renderingRegion;
400
+
401
+ if (eventType === 'pointerdown') {
402
+ const pointer = Pointer.ofEvent(evt, true, this.viewport, eventsRelativeTo);
403
+ this.pointers[pointer.id] = pointer;
404
+
405
+ eventTarget.setPointerCapture(pointer.id);
406
+ const event: PointerEvt = {
407
+ kind: InputEvtType.PointerDownEvt,
408
+ current: pointer,
409
+ allPointers: this.getPointerList(),
410
+ };
411
+ this.toolController.dispatchInputEvent(event);
412
+
413
+ return true;
414
+ }
415
+ else if (eventType === 'pointermove') {
416
+ const pointer = Pointer.ofEvent(
417
+ evt, this.pointers[evt.pointerId]?.down ?? false, this.viewport, eventsRelativeTo
418
+ );
419
+ if (pointer.down) {
420
+ const prevData = this.pointers[pointer.id];
421
+
422
+ if (prevData) {
423
+ const distanceMoved = pointer.screenPos.minus(prevData.screenPos).magnitude();
424
+
425
+ // If the pointer moved less than two pixels, don't send a new event.
426
+ if (distanceMoved < 2) {
427
+ return false;
428
+ }
429
+ }
430
+
431
+ this.pointers[pointer.id] = pointer;
432
+ if (this.toolController.dispatchInputEvent({
433
+ kind: InputEvtType.PointerMoveEvt,
434
+ current: pointer,
435
+ allPointers: this.getPointerList(),
436
+ })) {
437
+ evt.preventDefault();
438
+ }
439
+ }
440
+ return true;
441
+ }
442
+ else if (eventType === 'pointercancel' || eventType === 'pointerup') {
443
+ const pointer = Pointer.ofEvent(evt, false, this.viewport, eventsRelativeTo);
444
+ if (!this.pointers[pointer.id]) {
445
+ return false;
446
+ }
447
+
448
+ this.pointers[pointer.id] = pointer;
449
+ eventTarget.releasePointerCapture(pointer.id);
450
+ if (this.toolController.dispatchInputEvent({
451
+ kind: InputEvtType.PointerUpEvt,
452
+ current: pointer,
453
+ allPointers: this.getPointerList(),
454
+ })) {
455
+ evt.preventDefault();
456
+ }
457
+ delete this.pointers[pointer.id];
458
+ return true;
459
+ }
460
+
461
+ return eventType;
462
+ }
463
+
462
464
  private isEventSink(evtTarget: Element|EventTarget|null) {
463
465
  let currentElem: Element|null = evtTarget as Element|null;
464
466
  while (currentElem !== null) {
@@ -553,6 +555,27 @@ export class Editor {
553
555
  }
554
556
  }
555
557
 
558
+ public handlePointerEventsFrom(elem: HTMLElement, filter?: HTMLPointerEventFilter) {
559
+ // May be required to prevent text selection on iOS/Safari:
560
+ // See https://stackoverflow.com/a/70992717/17055750
561
+ elem.addEventListener('touchstart', evt => evt.preventDefault());
562
+ elem.addEventListener('contextmenu', evt => {
563
+ // Don't show a context menu
564
+ evt.preventDefault();
565
+ });
566
+
567
+ const eventNames: HTMLPointerEventType[] = ['pointerdown', 'pointermove', 'pointerup', 'pointercancel'];
568
+ for (const eventName of eventNames) {
569
+ elem.addEventListener(eventName, evt => {
570
+ if (filter && !filter(eventName, evt)) {
571
+ return true;
572
+ }
573
+
574
+ return this.handleHTMLPointerEvent(eventName, evt);
575
+ });
576
+ }
577
+ }
578
+
556
579
  /** Adds event listners for keypresses to `elem` and forwards those events to the editor. */
557
580
  public handleKeyEventsFrom(elem: HTMLElement) {
558
581
  elem.addEventListener('keydown', evt => {
@@ -567,7 +590,7 @@ export class Editor {
567
590
  evt.preventDefault();
568
591
  } else if (evt.key === 'Escape') {
569
592
  this.renderingRegion.blur();
570
- }
593
+ }
571
594
  });
572
595
 
573
596
  elem.addEventListener('keyup', evt => {
@@ -609,11 +632,11 @@ export class Editor {
609
632
  * Dispatches a command without announcing it. By default, does not add to history.
610
633
  * Use this to show finalized commands that don't need to have `announceForAccessibility`
611
634
  * called.
612
- *
635
+ *
613
636
  * Prefer `command.apply(editor)` for incomplete commands. `dispatchNoAnnounce` may allow
614
637
  * clients to listen for the application of commands (e.g. `SerializableCommand`s so they can
615
638
  * be sent across the network), while `apply` does not.
616
- *
639
+ *
617
640
  * @example
618
641
  * ```
619
642
  * const addToHistory = false;
@@ -755,6 +778,20 @@ export class Editor {
755
778
  return styleSheet;
756
779
  }
757
780
 
781
+ // Dispatch a keyboard event to the currently selected tool.
782
+ // Intended for unit testing
783
+ public sendKeyboardEvent(
784
+ eventType: InputEvtType.KeyPressEvent|InputEvtType.KeyUpEvent,
785
+ key: string,
786
+ ctrlKey: boolean = false
787
+ ) {
788
+ this.toolController.dispatchInputEvent({
789
+ kind: eventType,
790
+ key,
791
+ ctrlKey
792
+ });
793
+ }
794
+
758
795
  // Dispatch a pen event to the currently selected tool.
759
796
  // Intended primarially for unit tests.
760
797
  public sendPenEvent(
@@ -864,7 +901,7 @@ export class Editor {
864
901
 
865
902
  /**
866
903
  * Alias for loadFrom(SVGLoader.fromString).
867
- *
904
+ *
868
905
  * This is particularly useful when accessing a bundled version of the editor,
869
906
  * where `SVGLoader.fromString` is unavailable.
870
907
  */
package/src/Pointer.ts CHANGED
@@ -36,9 +36,14 @@ export default class Pointer {
36
36
  ) {
37
37
  }
38
38
 
39
- // Creates a Pointer from a DOM event.
40
- public static ofEvent(evt: PointerEvent, isDown: boolean, viewport: Viewport): Pointer {
41
- const screenPos = Vec2.of(evt.offsetX, evt.offsetY);
39
+ // Creates a Pointer from a DOM event. If `relativeTo` is given, (0, 0) in screen coordinates is
40
+ // considered the top left of `relativeTo`.
41
+ public static ofEvent(evt: PointerEvent, isDown: boolean, viewport: Viewport, relativeTo?: HTMLElement): Pointer {
42
+ let screenPos = Vec2.of(evt.clientX, evt.clientY);
43
+ if (relativeTo) {
44
+ const bbox = relativeTo.getBoundingClientRect();
45
+ screenPos = screenPos.minus(Vec2.of(bbox.left, bbox.top));
46
+ }
42
47
 
43
48
  const pointerTypeToDevice: Record<string, PointerDevice> = {
44
49
  'mouse': PointerDevice.PrimaryButtonMouse,
package/src/Viewport.ts CHANGED
@@ -146,7 +146,8 @@ export class Viewport {
146
146
  return 1/this.getScaleFactor();
147
147
  }
148
148
 
149
- // Returns the angle of the canvas in radians
149
+ // Returns the angle of the canvas in radians.
150
+ // This is the angle by which the canvas is rotated relative to the screen.
150
151
  public getRotationAngle(): number {
151
152
  return this.transform.transformVec3(Vec3.unitX).angle();
152
153
  }
@@ -175,12 +176,26 @@ export class Viewport {
175
176
  return point.map(roundComponent);
176
177
  }
177
178
 
178
-
179
179
  // Round a point with a tolerance of ±1 screen unit.
180
180
  public roundPoint(point: Point2): Point2 {
181
181
  return Viewport.roundPoint(point, 1 / this.getScaleFactor());
182
182
  }
183
183
 
184
+ // `roundAmount`: An integer >= 0, larger numbers cause less rounding. Smaller numbers cause more
185
+ // (as such `roundAmount = 0` does the most rounding).
186
+ public static roundScaleRatio(scaleRatio: number, roundAmount: number = 1): number {
187
+ if (Math.abs(scaleRatio) <= 1e-12) {
188
+ return 0;
189
+ }
190
+
191
+ // Represent as k 10ⁿ for some n, k ∈ ℤ.
192
+ const decimalComponent = 10 ** Math.floor(Math.log10(Math.abs(scaleRatio)));
193
+ const roundAnountFactor = 2 ** roundAmount;
194
+ scaleRatio = Math.round(scaleRatio / decimalComponent * roundAnountFactor) / roundAnountFactor * decimalComponent;
195
+
196
+ return scaleRatio;
197
+ }
198
+
184
199
  // Computes and returns an affine transformation that makes `toMakeVisible` visible and roughly centered on the screen.
185
200
  public computeZoomToTransform(toMakeVisible: Rect2, allowZoomIn: boolean = true, allowZoomOut: boolean = true): Mat33 {
186
201
  let transform = Mat33.identity;
@@ -2,7 +2,7 @@ import SerializableCommand from '../commands/SerializableCommand';
2
2
  import Editor from '../Editor';
3
3
  import EditorImage from '../EditorImage';
4
4
  import LineSegment2 from '../math/LineSegment2';
5
- import Mat33 from '../math/Mat33';
5
+ import Mat33, { Mat33Array } from '../math/Mat33';
6
6
  import Rect2 from '../math/Rect2';
7
7
  import { EditorLocalization } from '../localization';
8
8
  import AbstractRenderer from '../rendering/renderers/AbstractRenderer';
@@ -183,11 +183,7 @@ export default abstract class AbstractComponent {
183
183
  SerializableCommand.register(AbstractComponent.transformElementCommandId, (json: any, editor: Editor) => {
184
184
  const elem = editor.image.lookupElement(json.id);
185
185
 
186
- const transform = new Mat33(...(json.transfm as [
187
- number, number, number,
188
- number, number, number,
189
- number, number, number,
190
- ]));
186
+ const transform = new Mat33(...(json.transfm as Mat33Array));
191
187
 
192
188
  if (!elem) {
193
189
  return new AbstractComponent.UnresolvedTransformElementCommand(transform, json.id);
@@ -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';
@@ -141,11 +141,7 @@ export default class ImageComponent extends AbstractComponent {
141
141
  image: image,
142
142
  base64Url: image.src,
143
143
  label: data.label,
144
- transform: new Mat33(...(data.transform as [
145
- number, number, number,
146
- number, number, number,
147
- number, number, number,
148
- ])),
144
+ transform: new Mat33(...(data.transform as Mat33Array)),
149
145
  });
150
146
  }
151
147
  }
@@ -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 from '../rendering/renderers/AbstractRenderer';
5
5
  import RenderingStyle, { styleFromJSON, styleToJSON } from '../rendering/RenderingStyle';
@@ -200,11 +200,7 @@ export default class Text extends AbstractComponent {
200
200
  throw new Error(`Unable to deserialize transform, ${json.transform}.`);
201
201
  }
202
202
 
203
- const transformData = json.transform as [
204
- number, number, number,
205
- number, number, number,
206
- number, number, number,
207
- ];
203
+ const transformData = json.transform as Mat33Array;
208
204
  const transform = new Mat33(...transformData);
209
205
 
210
206
  return new Text(textObjects, transform, style, getTextDimens);
@@ -0,0 +1,6 @@
1
+
2
+ // Compile-time assertion that a branch of code is unreachable.
3
+ // See https://stackoverflow.com/a/39419171/17055750
4
+ export const assertUnreachable = (key: never): never => {
5
+ throw new Error(`Should be unreachable. Key: ${key}.`);
6
+ };
@@ -142,6 +142,20 @@ describe('Mat33 tests', () => {
142
142
  ).objEq(Vec2.unitX, fuzz);
143
143
  });
144
144
 
145
+ it('should correctly apply a mapping to all components', () => {
146
+ expect(
147
+ new Mat33(
148
+ 1, 2, 3,
149
+ 4, 5, 6,
150
+ 7, 8, 9,
151
+ ).mapEntries(component => component - 1)
152
+ ).toMatchObject(new Mat33(
153
+ 0, 1, 2,
154
+ 3, 4, 5,
155
+ 6, 7, 8,
156
+ ));
157
+ });
158
+
145
159
  it('should convert CSS matrix(...) strings to matricies', () => {
146
160
  // From MDN:
147
161
  // ⎡ a c e ⎤
package/src/math/Mat33.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import { Point2, Vec2 } from './Vec2';
2
2
  import Vec3 from './Vec3';
3
3
 
4
+ export type Mat33Array = [
5
+ number, number, number,
6
+ number, number, number,
7
+ number, number, number,
8
+ ];
9
+
4
10
  /**
5
11
  * Represents a three dimensional linear transformation or
6
12
  * a two-dimensional affine transformation. (An affine transformation scales/rotates/shears
@@ -239,7 +245,7 @@ export default class Mat33 {
239
245
  * ...
240
246
  * ```
241
247
  */
242
- public toArray(): number[] {
248
+ public toArray(): Mat33Array {
243
249
  return [
244
250
  this.a1, this.a2, this.a3,
245
251
  this.b1, this.b2, this.b3,
@@ -247,6 +253,27 @@ export default class Mat33 {
247
253
  ];
248
254
  }
249
255
 
256
+ /**
257
+ * @example
258
+ * ```
259
+ * new Mat33(
260
+ * 1, 2, 3,
261
+ * 4, 5, 6,
262
+ * 7, 8, 9,
263
+ * ).mapEntries(component => component - 1);
264
+ * // → ⎡ 0, 1, 2 ⎤
265
+ * // ⎢ 3, 4, 5 ⎥
266
+ * // ⎣ 6, 7, 8 ⎦
267
+ * ```
268
+ */
269
+ public mapEntries(mapping: (component: number)=>number): Mat33 {
270
+ return new Mat33(
271
+ mapping(this.a1), mapping(this.a2), mapping(this.a3),
272
+ mapping(this.b1), mapping(this.b2), mapping(this.b3),
273
+ mapping(this.c1), mapping(this.c2), mapping(this.c3),
274
+ );
275
+ }
276
+
250
277
  /** Constructs a 3x3 translation matrix (for translating `Vec2`s) */
251
278
  public static translation(amount: Vec2): Mat33 {
252
279
  // When transforming Vec2s by a 3x3 matrix, we give the input
@@ -297,7 +324,21 @@ export default class Mat33 {
297
324
  return result.rightMul(Mat33.translation(center.times(-1)));
298
325
  }
299
326
 
300
- /** Converts a CSS-form `matrix(a, b, c, d, e, f)` to a Mat33. */
327
+ /** @see {@link !fromCSSMatrix} */
328
+ public toCSSMatrix(): string {
329
+ return `matrix(${this.a1},${this.b1},${this.a2},${this.b2},${this.a3},${this.b3})`;
330
+ }
331
+
332
+ /**
333
+ * Converts a CSS-form `matrix(a, b, c, d, e, f)` to a Mat33.
334
+ *
335
+ * Note that such a matrix has the form,
336
+ * ```
337
+ * ⎡ a c e ⎤
338
+ * ⎢ b d f ⎥
339
+ * ⎣ 0 0 1 ⎦
340
+ * ```
341
+ */
301
342
  public static fromCSSMatrix(cssString: string): Mat33 {
302
343
  if (cssString === '' || cssString === 'none') {
303
344
  return Mat33.identity;
@@ -38,7 +38,7 @@ describe('Path.toString', () => {
38
38
  const path = new Path(Vec2.of(1000, 2_000_000), [
39
39
  {
40
40
  kind: PathCommandType.LineTo,
41
- point: Vec2.of(30.0001, 40.000000001),
41
+ point: Vec2.of(30.00000001, 40.000000001),
42
42
  },
43
43
  ]);
44
44
 
@@ -53,4 +53,15 @@ describe('Path.toString', () => {
53
53
  'M100,100', 'l1,1', 'q1,1 -11-11', 'l10,10'
54
54
  ].join(''));
55
55
  });
56
+
57
+ it('should not lose precision when saving', () => {
58
+ const pathStr = 'M184.2,52.3l-.2-.2q-2.7,2.4 -3.2,3.5q-2.8,7 -.9,6.1q4.3-2.6 4.8-6.1q1.2-8.8 .4-8.3q-4.2,5.2 -3.9,3.9q.2-1.6 .3-2.1q.2-1.3 -.2-1q-3.8,6.5 -3.2,3.3q.6-4.1 1.1-5.3q4.1-10 3.3-8.3q-5.3,13.1 -6.6,14.1q-3.3,2.8 -1.8-1.5q2.8-9.7 2.7-8.4q0,.3 0,.4q-1.4,7.1 -2.7,8.5q-2.6,3.2 -2.5,2.9q-.3-1.9 -.7-1.9q-4.1,4.4 -2.9,1.9q1.1-3 .3-2.6q-1.8,2 -2.5,2.4q-4.5,2.8 -4.2,1.9q.3-1.6 .2-1.4q1.5,2.2 1.3,2.9q-.8,3.9 -.5,3.3q.8-7.6 2.5-13.3q2.6-9.2 2.9-6.9q.3,1.4 .3,1.2q-.7-.4 -.9,0q-2.2,11.6 -7.6,13.6q-3.9,1.6 -2.1-1.3q3-5.5 2.6-3.4q-.2,1.8 -.5,1.8q-3.2,.5 -4.1,1.2q-2.6,2.6 -1.9,2.5q4.7-4.4 3.7-5.5q-1.1-.9 -1.6-.6q-7.2,7.5 -3.9,6.5q.3-.1 .4-.4q.6-5.3 -.2-4.9q-2.8,2.3 -3.1,2.4q-3.7,1.5 -3.5,.5q.3-3.6 1.4-3.3q3.5,.7 1.9,2.4q-1.7,2.3 -1.6,.8q0-3.5 -.9-3.1q-5.1,3.3 -4.9,2.8q.1-4 -.8-3.5q-4.3,3.4 -4.6,2.5q-1-2.1 .5-8.7l-.2,0q-1.6,6.6 -.7,8.9q.7,1.2 5.2-2.3q.4-.5 .2,3.1q.1,1 5.5-2.4q.4-.4 .3,2.7q.1,2 2.4-.4q1.7-2.3 -2.1-3.2q-1.7-.3 -2,3.7q0,1.4 4.1-.1q.3-.1 3.1-2.4q.3-.5 -.4,4.5q0-.1 -.2,0q-2.6,1.2 4.5-5.7q0-.2 .8,.6q.9,.6 -3.7,4.7q-.5,1 2.7-1.7q.6-.7 3.7-1.2q.7-.2 .9-2.2q.1-2.7 -3.4,3.2q-1.8,3.4 2.7,1.9q5.6-2.1 7.8-14q-.1,.1 .3,.4q.6,.1 .3-1.6q-.7-2.8 -3.7,6.7q-1.8,5.8 -2.5,13.5q.1,1.1 1.3-3.1q.2-1 -1.3-3.3q-.5-.5 -1,1.6q-.1,1.3 4.8-1.5q1-1 3-2q.1-.4 -1.1,2q-1.1,3.1 3.7-1.3q-.4,0 -.1,1.5q.3,.8 3.3-2.5q1.3-1.6 2.7-8.9q0-.1 0-.4q-.3-1.9 -3.5,8.2q-1.3,4.9 2.4,2.1q1.4-1.2 6.6-14.3q.8-2.4 -3.9,7.9q-.6,1.3 -1.1,5.5q-.3,3.7 4-3.1q-.2,0 -.6,.6q-.2,.6 -.3,2.3q0,1.8 4.7-3.5q.1-.5 -1.2,7.9q-.5,3.2 -4.6,5.7q-1.3,1 1.5-5.5q.4-1.1 3.01-3.5';
59
+
60
+ const path1 = Path.fromString(pathStr);
61
+ path1['cachedStringVersion'] = null; // Clear the cache.
62
+ const path = Path.fromString(path1.toString(true));
63
+ path1['cachedStringVersion'] = null; // Clear the cache.
64
+
65
+ expect(path.toString(true)).toBe(path1.toString(true));
66
+ });
56
67
  });
package/src/math/Path.ts CHANGED
@@ -378,15 +378,17 @@ export default class Path {
378
378
 
379
379
  private cachedStringVersion: string|null = null;
380
380
 
381
- public toString(): string {
381
+ public toString(useNonAbsCommands?: boolean): string {
382
382
  if (this.cachedStringVersion) {
383
383
  return this.cachedStringVersion;
384
384
  }
385
385
 
386
- // Hueristic: Try to determine whether converting absolute to relative commands is worth it.
387
- const makeRelativeCommands = Math.abs(this.bbox.topLeft.x) > 10 && Math.abs(this.bbox.topLeft.y) > 10;
386
+ if (useNonAbsCommands === undefined) {
387
+ // Hueristic: Try to determine whether converting absolute to relative commands is worth it.
388
+ useNonAbsCommands = Math.abs(this.bbox.topLeft.x) > 10 && Math.abs(this.bbox.topLeft.y) > 10;
389
+ }
388
390
 
389
- const result = Path.toString(this.startPoint, this.parts, !makeRelativeCommands);
391
+ const result = Path.toString(this.startPoint, this.parts, !useNonAbsCommands);
390
392
  this.cachedStringVersion = result;
391
393
  return result;
392
394
  }
@@ -409,10 +411,13 @@ export default class Path {
409
411
  const roundedPrevY = prevPoint ? toRoundedString(prevPoint.y) : '';
410
412
 
411
413
  for (const point of points) {
414
+ const xComponent = toRoundedString(point.x);
415
+ const yComponent = toRoundedString(point.y);
416
+
412
417
  // Relative commands are often shorter as strings than absolute commands.
413
418
  if (!makeAbsCommand) {
414
- const xComponentRelative = toStringOfSamePrecision(point.x - prevPoint!.x, roundedPrevX, roundedPrevY);
415
- const yComponentRelative = toStringOfSamePrecision(point.y - prevPoint!.y, roundedPrevX, roundedPrevY);
419
+ const xComponentRelative = toStringOfSamePrecision(point.x - prevPoint!.x, xComponent, roundedPrevX, roundedPrevY);
420
+ const yComponentRelative = toStringOfSamePrecision(point.y - prevPoint!.y, yComponent, roundedPrevX, roundedPrevY);
416
421
 
417
422
  // No need for an additional separator if it starts with a '-'
418
423
  if (yComponentRelative.charAt(0) === '-') {
@@ -421,9 +426,6 @@ export default class Path {
421
426
  relativeCommandParts.push(`${xComponentRelative},${yComponentRelative}`);
422
427
  }
423
428
  } else {
424
- const xComponent = toRoundedString(point.x);
425
- const yComponent = toRoundedString(point.y);
426
-
427
429
  absoluteCommandParts.push(`${xComponent},${yComponent}`);
428
430
  }
429
431
  }