js-draw 0.3.2 → 0.4.1

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 (104) hide show
  1. package/.github/pull_request_template.md +15 -0
  2. package/.github/workflows/firebase-hosting-merge.yml +7 -0
  3. package/.github/workflows/firebase-hosting-pull-request.yml +10 -0
  4. package/.github/workflows/github-pages.yml +2 -0
  5. package/CHANGELOG.md +16 -1
  6. package/README.md +1 -3
  7. package/dist/bundle.js +1 -1
  8. package/dist/src/Editor.d.ts +11 -0
  9. package/dist/src/Editor.js +107 -77
  10. package/dist/src/Pointer.d.ts +1 -1
  11. package/dist/src/Pointer.js +8 -3
  12. package/dist/src/Viewport.d.ts +1 -0
  13. package/dist/src/Viewport.js +14 -1
  14. package/dist/src/components/AbstractComponent.js +1 -0
  15. package/dist/src/components/ImageComponent.d.ts +2 -2
  16. package/dist/src/components/Stroke.js +15 -9
  17. package/dist/src/components/Text.d.ts +1 -1
  18. package/dist/src/components/Text.js +1 -1
  19. package/dist/src/components/builders/FreehandLineBuilder.d.ts +1 -0
  20. package/dist/src/components/builders/FreehandLineBuilder.js +34 -36
  21. package/dist/src/language/assertions.d.ts +1 -0
  22. package/dist/src/language/assertions.js +5 -0
  23. package/dist/src/math/Mat33.d.ts +38 -2
  24. package/dist/src/math/Mat33.js +30 -1
  25. package/dist/src/math/Path.d.ts +1 -1
  26. package/dist/src/math/Path.js +10 -8
  27. package/dist/src/math/Vec3.d.ts +12 -2
  28. package/dist/src/math/Vec3.js +16 -1
  29. package/dist/src/math/rounding.d.ts +1 -0
  30. package/dist/src/math/rounding.js +13 -6
  31. package/dist/src/rendering/renderers/AbstractRenderer.js +2 -1
  32. package/dist/src/testing/beforeEachFile.d.ts +1 -0
  33. package/dist/src/testing/beforeEachFile.js +3 -0
  34. package/dist/src/testing/createEditor.d.ts +1 -0
  35. package/dist/src/testing/createEditor.js +7 -1
  36. package/dist/src/testing/loadExpectExtensions.d.ts +0 -15
  37. package/dist/src/toolbar/HTMLToolbar.js +5 -4
  38. package/dist/src/toolbar/widgets/SelectionToolWidget.d.ts +1 -1
  39. package/dist/src/tools/PasteHandler.js +3 -1
  40. package/dist/src/tools/Pen.js +1 -1
  41. package/dist/src/tools/SelectionTool/Selection.d.ts +54 -0
  42. package/dist/src/tools/SelectionTool/Selection.js +337 -0
  43. package/dist/src/tools/SelectionTool/SelectionHandle.d.ts +35 -0
  44. package/dist/src/tools/SelectionTool/SelectionHandle.js +75 -0
  45. package/dist/src/tools/SelectionTool/SelectionTool.d.ts +31 -0
  46. package/dist/src/tools/SelectionTool/SelectionTool.js +284 -0
  47. package/dist/src/tools/SelectionTool/TransformMode.d.ts +34 -0
  48. package/dist/src/tools/SelectionTool/TransformMode.js +98 -0
  49. package/dist/src/tools/SelectionTool/types.d.ts +9 -0
  50. package/dist/src/tools/SelectionTool/types.js +11 -0
  51. package/dist/src/tools/ToolController.js +1 -1
  52. package/dist/src/tools/lib.d.ts +1 -1
  53. package/dist/src/tools/lib.js +1 -1
  54. package/dist/src/types.d.ts +1 -1
  55. package/jest.config.js +5 -0
  56. package/package.json +15 -14
  57. package/src/Editor.css +1 -0
  58. package/src/Editor.ts +147 -108
  59. package/src/Pointer.ts +8 -3
  60. package/src/Viewport.ts +17 -2
  61. package/src/components/AbstractComponent.ts +4 -6
  62. package/src/components/ImageComponent.ts +2 -6
  63. package/src/components/Stroke.test.ts +0 -3
  64. package/src/components/Stroke.ts +14 -7
  65. package/src/components/Text.test.ts +0 -3
  66. package/src/components/Text.ts +4 -8
  67. package/src/components/builders/FreehandLineBuilder.ts +37 -43
  68. package/src/language/assertions.ts +6 -0
  69. package/src/math/LineSegment2.test.ts +8 -10
  70. package/src/math/Mat33.test.ts +14 -2
  71. package/src/math/Mat33.ts +43 -2
  72. package/src/math/Path.toString.test.ts +12 -1
  73. package/src/math/Path.ts +11 -9
  74. package/src/math/Rect2.test.ts +0 -3
  75. package/src/math/Vec2.test.ts +0 -3
  76. package/src/math/Vec3.test.ts +0 -3
  77. package/src/math/Vec3.ts +23 -2
  78. package/src/math/rounding.test.ts +30 -5
  79. package/src/math/rounding.ts +16 -7
  80. package/src/rendering/renderers/AbstractRenderer.ts +3 -2
  81. package/src/testing/beforeEachFile.ts +3 -0
  82. package/src/testing/createEditor.ts +8 -1
  83. package/src/testing/global.d.ts +17 -0
  84. package/src/testing/loadExpectExtensions.ts +0 -15
  85. package/src/toolbar/HTMLToolbar.ts +5 -4
  86. package/src/toolbar/toolbar.css +3 -2
  87. package/src/toolbar/widgets/SelectionToolWidget.ts +1 -1
  88. package/src/tools/PasteHandler.ts +4 -1
  89. package/src/tools/Pen.test.ts +150 -0
  90. package/src/tools/Pen.ts +1 -1
  91. package/src/tools/SelectionTool/Selection.ts +455 -0
  92. package/src/tools/SelectionTool/SelectionHandle.ts +99 -0
  93. package/src/tools/SelectionTool/SelectionTool.css +22 -0
  94. package/src/tools/{SelectionTool.test.ts → SelectionTool/SelectionTool.test.ts} +21 -21
  95. package/src/tools/SelectionTool/SelectionTool.ts +344 -0
  96. package/src/tools/SelectionTool/TransformMode.ts +114 -0
  97. package/src/tools/SelectionTool/types.ts +11 -0
  98. package/src/tools/ToolController.ts +1 -1
  99. package/src/tools/lib.ts +1 -1
  100. package/src/types.ts +1 -1
  101. package/tsconfig.json +3 -1
  102. package/dist/src/tools/SelectionTool.d.ts +0 -65
  103. package/dist/src/tools/SelectionTool.js +0 -647
  104. 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,9 +596,20 @@ 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
- sendPenEvent(eventType, point, allPointers) {
610
+ sendPenEvent(eventType, point,
611
+ // @deprecated
612
+ allPointers) {
583
613
  const mainPointer = Pointer.ofCanvasPoint(point, eventType !== InputEvtType.PointerUpEvt, this.viewport);
584
614
  this.toolController.dispatchInputEvent({
585
615
  kind: eventType,
@@ -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;
@@ -48,6 +48,7 @@ export default class AbstractComponent {
48
48
  transformBy(affineTransfm) {
49
49
  return new AbstractComponent.TransformElementCommand(affineTransfm, this);
50
50
  }
51
+ // Returns a copy of this component.
51
52
  clone() {
52
53
  const clone = this.createClone();
53
54
  for (const attachmentKey in this.loadSaveData) {
@@ -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;
@@ -6,7 +6,8 @@ export default class Stroke extends AbstractComponent {
6
6
  constructor(parts) {
7
7
  var _a;
8
8
  super('stroke');
9
- this.parts = parts.map((section) => {
9
+ this.parts = [];
10
+ for (const section of parts) {
10
11
  const path = Path.fromRenderable(section);
11
12
  const pathBBox = this.bboxForPart(path.bbox, section.style);
12
13
  if (!this.contentBBox) {
@@ -15,14 +16,14 @@ export default class Stroke extends AbstractComponent {
15
16
  else {
16
17
  this.contentBBox = this.contentBBox.union(pathBBox);
17
18
  }
18
- return {
19
+ this.parts.push({
19
20
  path,
20
21
  // To implement RenderablePathSpec
21
22
  startPoint: path.startPoint,
22
23
  style: section.style,
23
24
  commands: path.parts,
24
- };
25
- });
25
+ });
26
+ }
26
27
  (_a = this.contentBBox) !== null && _a !== void 0 ? _a : (this.contentBBox = Rect2.empty);
27
28
  }
28
29
  intersects(line) {
@@ -80,11 +81,16 @@ export default class Stroke extends AbstractComponent {
80
81
  });
81
82
  }
82
83
  getPath() {
83
- var _a;
84
- return (_a = this.parts.reduce((accumulator, current) => {
85
- var _a;
86
- return (_a = accumulator === null || accumulator === void 0 ? void 0 : accumulator.union(current.path)) !== null && _a !== void 0 ? _a : current.path;
87
- }, null)) !== null && _a !== void 0 ? _a : Path.empty;
84
+ let result = null;
85
+ for (const part of this.parts) {
86
+ if (result) {
87
+ result = result.union(part.path);
88
+ }
89
+ else {
90
+ result !== null && result !== void 0 ? result : (result = part.path);
91
+ }
92
+ }
93
+ return result !== null && result !== void 0 ? result : Path.empty;
88
94
  }
89
95
  description(localization) {
90
96
  return localization.stroke;
@@ -29,7 +29,7 @@ export default class Text extends AbstractComponent {
29
29
  intersects(lineSegment: LineSegment2): boolean;
30
30
  protected applyTransformation(affineTransfm: Mat33): void;
31
31
  protected createClone(): AbstractComponent;
32
- private getText;
32
+ getText(): string;
33
33
  description(localizationTable: ImageComponentLocalization): string;
34
34
  protected serializeToJSON(): Record<string, any>;
35
35
  static deserializeFromString(json: any, getTextDimens?: GetTextDimensCallback): Text;
@@ -113,7 +113,7 @@ export default class Text extends AbstractComponent {
113
113
  result.push(textObject.getText());
114
114
  }
115
115
  }
116
- return result.join(' ');
116
+ return result.join('\n');
117
117
  }
118
118
  description(localizationTable) {
119
119
  return localizationTable.text(this.getText());
@@ -29,6 +29,7 @@ export default class FreehandLineBuilder implements ComponentBuilder {
29
29
  preview(renderer: AbstractRenderer): void;
30
30
  build(): Stroke;
31
31
  private roundPoint;
32
+ private approxCurrentCurveLength;
32
33
  private finalizeCurrentCurve;
33
34
  private currentSegmentToPath;
34
35
  private computeExitingVec;
@@ -117,7 +117,7 @@ export default class FreehandLineBuilder {
117
117
  }
118
118
  }
119
119
  build() {
120
- if (this.lastPoint) {
120
+ if (this.lastPoint && (this.lowerSegments.length === 0 || this.approxCurrentCurveLength() > this.curveStartWidth * 2)) {
121
121
  this.finalizeCurrentCurve();
122
122
  }
123
123
  return this.previewStroke();
@@ -129,6 +129,18 @@ export default class FreehandLineBuilder {
129
129
  }
130
130
  return Viewport.roundPoint(point, minFit);
131
131
  }
132
+ // Returns the distance between the start, control, and end points of the curve.
133
+ approxCurrentCurveLength() {
134
+ if (!this.currentCurve) {
135
+ return 0;
136
+ }
137
+ const startPt = Vec2.ofXY(this.currentCurve.points[0]);
138
+ const controlPt = Vec2.ofXY(this.currentCurve.points[1]);
139
+ const endPt = Vec2.ofXY(this.currentCurve.points[2]);
140
+ const toControlDist = startPt.minus(controlPt).length();
141
+ const toEndDist = endPt.minus(controlPt).length();
142
+ return toControlDist + toEndDist;
143
+ }
132
144
  finalizeCurrentCurve() {
133
145
  // Case where no points have been added
134
146
  if (!this.currentCurve) {
@@ -204,10 +216,8 @@ export default class FreehandLineBuilder {
204
216
  let endVec = Vec2.ofXY(this.currentCurve.normal(1)).normalized();
205
217
  startVec = startVec.times(this.curveStartWidth / 2);
206
218
  endVec = endVec.times(this.curveEndWidth / 2);
207
- if (isNaN(startVec.magnitude())) {
208
- // TODO: This can happen when events are too close together. Find out why and
209
- // fix.
210
- console.error('startVec is NaN', startVec, endVec, this.currentCurve);
219
+ if (!isFinite(startVec.magnitude())) {
220
+ console.error('Warning: startVec is NaN or ∞', startVec, endVec, this.currentCurve);
211
221
  startVec = endVec;
212
222
  }
213
223
  const startPt = Vec2.ofXY(this.currentCurve.get(0));
@@ -224,28 +234,18 @@ export default class FreehandLineBuilder {
224
234
  }
225
235
  }
226
236
  const halfVecT = projectionT;
227
- let halfVec = Vec2.ofXY(this.currentCurve.normal(halfVecT))
237
+ const halfVec = Vec2.ofXY(this.currentCurve.normal(halfVecT))
228
238
  .normalized().times(this.curveStartWidth / 2 * halfVecT
229
239
  + this.curveEndWidth / 2 * (1 - halfVecT));
230
- // Computes a boundary curve. [direction] should be either +1 or -1 (determines the side
231
- // of the center curve to place the boundary).
232
- const computeBoundaryCurve = (direction, halfVec) => {
233
- return new Bezier(startPt.plus(startVec.times(direction)), controlPoint.plus(halfVec.times(direction)), endPt.plus(endVec.times(direction)));
234
- };
235
- const boundariesIntersect = () => {
236
- const upperBoundary = computeBoundaryCurve(1, halfVec);
237
- const lowerBoundary = computeBoundaryCurve(-1, halfVec);
238
- return upperBoundary.intersects(lowerBoundary).length > 0;
239
- };
240
- // If the boundaries have intersections, increasing the half vector's length could fix this.
241
- if (boundariesIntersect()) {
242
- halfVec = halfVec.times(1.1);
243
- }
244
240
  // Each starts at startPt ± startVec
241
+ const lowerCurveControlPoint = this.roundPoint(controlPoint.plus(halfVec));
242
+ const lowerCurveEndPoint = this.roundPoint(endPt.plus(endVec));
243
+ const upperCurveControlPoint = this.roundPoint(controlPoint.minus(halfVec));
244
+ const upperCurveStartPoint = this.roundPoint(endPt.minus(endVec));
245
245
  const lowerCurve = {
246
246
  kind: PathCommandType.QuadraticBezierTo,
247
- controlPoint: this.roundPoint(controlPoint.plus(halfVec)),
248
- endPoint: this.roundPoint(endPt.plus(endVec)),
247
+ controlPoint: lowerCurveControlPoint,
248
+ endPoint: lowerCurveEndPoint,
249
249
  };
250
250
  // From the end of the upperCurve to the start of the lowerCurve:
251
251
  const upperToLowerConnector = {
@@ -255,11 +255,11 @@ export default class FreehandLineBuilder {
255
255
  // From the end of lowerCurve to the start of upperCurve:
256
256
  const lowerToUpperConnector = {
257
257
  kind: PathCommandType.LineTo,
258
- point: this.roundPoint(endPt.minus(endVec))
258
+ point: upperCurveStartPoint,
259
259
  };
260
260
  const upperCurve = {
261
261
  kind: PathCommandType.QuadraticBezierTo,
262
- controlPoint: this.roundPoint(controlPoint.minus(halfVec)),
262
+ controlPoint: upperCurveControlPoint,
263
263
  endPoint: this.roundPoint(startPt.minus(startVec)),
264
264
  };
265
265
  return { upperCurve, upperToLowerConnector, lowerToUpperConnector, lowerCurve };
@@ -275,7 +275,6 @@ export default class FreehandLineBuilder {
275
275
  const fuzzEq = 1e-10;
276
276
  const deltaTime = newPoint.time - this.lastPoint.time;
277
277
  if (newPoint.pos.eq(this.lastPoint.pos, fuzzEq) || deltaTime === 0) {
278
- console.warn('Discarding identical point');
279
278
  return;
280
279
  }
281
280
  else if (isNaN(newPoint.pos.magnitude())) {
@@ -321,35 +320,35 @@ export default class FreehandLineBuilder {
321
320
  }
322
321
  let exitingVec = this.computeExitingVec();
323
322
  // Find the intersection between the entering vector and the exiting vector
324
- const maxRelativeLength = 2;
323
+ const maxRelativeLength = 3;
325
324
  const segmentStart = this.buffer[0];
326
325
  const segmentEnd = newPoint.pos;
327
326
  const startEndDist = segmentEnd.minus(segmentStart).magnitude();
328
327
  const maxControlPointDist = maxRelativeLength * startEndDist;
329
328
  // Exit in cases where we would divide by zero
330
- if (maxControlPointDist === 0 || exitingVec.magnitude() === 0 || isNaN(exitingVec.magnitude())) {
329
+ if (maxControlPointDist === 0 || exitingVec.magnitude() === 0 || !isFinite(exitingVec.magnitude())) {
331
330
  return;
332
331
  }
333
- console.assert(!isNaN(enteringVec.magnitude()));
332
+ console.assert(isFinite(enteringVec.magnitude()), 'Pre-normalized enteringVec has NaN or ∞ magnitude!');
334
333
  enteringVec = enteringVec.normalized();
335
334
  exitingVec = exitingVec.normalized();
336
- console.assert(!isNaN(enteringVec.magnitude()));
335
+ console.assert(isFinite(enteringVec.magnitude()), 'Normalized enteringVec has NaN or ∞ magnitude!');
337
336
  const lineFromStart = new LineSegment2(segmentStart, segmentStart.plus(enteringVec.times(maxControlPointDist)));
338
337
  const lineFromEnd = new LineSegment2(segmentEnd.minus(exitingVec.times(maxControlPointDist)), segmentEnd);
339
338
  const intersection = lineFromEnd.intersection(lineFromStart);
340
339
  // Position the control point at this intersection
341
- let controlPoint;
340
+ let controlPoint = null;
342
341
  if (intersection) {
343
342
  controlPoint = intersection.point;
344
343
  }
345
- else {
344
+ // No intersection or the intersection is one of the end points?
345
+ if (!controlPoint || segmentStart.eq(controlPoint) || segmentEnd.eq(controlPoint)) {
346
346
  // Position the control point closer to the first -- the connecting
347
347
  // segment will be roughly a line.
348
348
  controlPoint = segmentStart.plus(enteringVec.times(startEndDist / 4));
349
349
  }
350
- if (isNaN(controlPoint.magnitude()) || isNaN(segmentStart.magnitude())) {
351
- console.error('controlPoint is NaN', intersection, 'Start:', segmentStart, 'End:', segmentEnd, 'in:', enteringVec, 'out:', exitingVec);
352
- }
350
+ console.assert(!segmentStart.eq(controlPoint, 1e-11), 'Start and control points are equal!');
351
+ console.assert(!controlPoint.eq(segmentEnd, 1e-11), 'Control and end points are equal!');
353
352
  const prevCurve = this.currentCurve;
354
353
  this.currentCurve = new Bezier(segmentStart.xy, controlPoint.xy, segmentEnd.xy);
355
354
  if (isNaN(Vec2.ofXY(this.currentCurve.normal(0)).magnitude())) {
@@ -369,8 +368,7 @@ export default class FreehandLineBuilder {
369
368
  }
370
369
  return true;
371
370
  };
372
- const approxCurveLen = controlPoint.minus(segmentStart).magnitude() + segmentEnd.minus(controlPoint).magnitude();
373
- if (this.buffer.length > 3 && approxCurveLen > this.curveEndWidth / 3) {
371
+ if (this.buffer.length > 3 && this.approxCurrentCurveLength() > this.curveStartWidth) {
374
372
  if (!curveMatchesPoints(this.currentCurve)) {
375
373
  // Use a curve that better fits the points
376
374
  this.currentCurve = prevCurve;
@@ -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
  }